diff --git a/Dockerfile b/Dockerfile index a42bd98a..aa2587e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -65,7 +65,7 @@ COPY backend/package*.json . COPY backend/regclient-napi ./regclient-napi COPY --from=backend_deps /app/node_modules ./node_modules RUN --mount=type=cache,target=/root/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/root/.cache \ npm rebuild --foreground-scripts=true regclient-napi # BACKEND: run type checker diff --git a/backend/.npmrc b/backend/.npmrc new file mode 100644 index 00000000..41583e36 --- /dev/null +++ b/backend/.npmrc @@ -0,0 +1 @@ +@jsr:registry=https://npm.jsr.io diff --git a/backend/eslint.config.js b/backend/eslint.config.js index 60e17046..9c97b660 100644 --- a/backend/eslint.config.js +++ b/backend/eslint.config.js @@ -94,6 +94,10 @@ export default defineConfig({ from: ["db", "jobs"], allow: ["prisma-generated"], }, + { + from: "jobs", + allow: ["db", "services"], + }, { from: "index", allow: ["env", "db", "server", "services/index"], @@ -112,8 +116,8 @@ export default defineConfig({ "boundaries/elements": [ // Modules that can only access the modules which are directly related. // Handlers -> Services -> DB - { type: "services", pattern: "service/**" }, - { type: "db", pattern: "db/**" }, + { type: "services", pattern: "src/service/**" }, + { type: "db", pattern: "src/db/**" }, { type: "handlers", pattern: "handlers/**" }, { type: "lib", pattern: "lib/**" }, // Jobs should be separate because some files have side effects that are undesirable in jobs (e.g. validating environment variables and throwing an error if they aren't present) diff --git a/backend/package-lock.json b/backend/package-lock.json index 4a214d54..d144acd2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "UNLICENSED", "dependencies": { + "@fishballpkg/acme": "npm:@jsr/fishballpkg__acme@^0.15.0", "@kubernetes/client-node": "^1.4.0", "@octokit/webhooks": "^14.1.3", "@opentelemetry/api": "^1.9.0", @@ -28,6 +29,7 @@ "body-parser": "^2.2.1", "connect-pg-simple": "^10.0.0", "cookie-parser": "^1.4.7", + "es-arraybuffer-base64": "^1.1.2", "express": "^5.1.0", "express-rate-limit": "^8.2.1", "express-session": "^1.18.2", @@ -40,7 +42,9 @@ "pg": "^8.16.3", "pino": "^10.1.1", "pino-http": "^11.0.0", - "regclient-napi": "file:./regclient-napi" + "pino-pretty": "^13.1.3", + "regclient-napi": "file:./regclient-napi", + "tldts": "^7.0.28" }, "devDependencies": { "@electric-sql/pglite": "^0.4.1", @@ -330,6 +334,12 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fishballpkg/acme": { + "name": "@jsr/fishballpkg__acme", + "version": "0.15.0", + "resolved": "https://npm.jsr.io/~/11/@jsr/fishballpkg__acme/0.15.0.tgz", + "integrity": "sha512-W3pPSnY9RfrI0X6VQUb6NSu4Da5I42/SzTlj9JpZRCkQXrKovNUaZ9Lmh/Lt7GwRuksptG8/KahQ8QfMaqd+bQ==" + }, "node_modules/@grpc/grpc-js": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", @@ -500,9 +510,9 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", - "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, @@ -4500,6 +4510,43 @@ "version": "2.0.1", "license": "Python-2.0" }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4510,6 +4557,15 @@ "node": ">=12" } }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/asynckit": { "version": "0.4.0", "license": "MIT" @@ -4521,6 +4577,21 @@ "node": ">=8.0.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "devOptional": true, @@ -4729,12 +4800,14 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -4910,6 +4983,12 @@ "version": "1.1.4", "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "license": "MIT", @@ -5034,6 +5113,66 @@ "node": ">= 12" } }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.3", "license": "MIT", @@ -5079,6 +5218,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/defu": { "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", @@ -5192,6 +5348,25 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "license": "MIT", @@ -5212,6 +5387,103 @@ "dev": true, "license": "MIT" }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-arraybuffer-base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-arraybuffer-base64/-/es-arraybuffer-base64-1.1.2.tgz", + "integrity": "sha512-l1yz/e0t3dFkKez8NvolmmiiXixOIeJ96id5Q4iL5AzEipU1L9UG5YQhbMmAkIXOWzfREP/mnN1p1XFv11sk3g==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.3", + "is-typed-array": "^1.1.13", + "math-intrinsics": "^1.0.0", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.3", + "typed-array-length": "^1.0.7", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "license": "MIT", @@ -5256,6 +5528,23 @@ "node": ">= 0.4" } }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5597,12 +5886,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -5614,15 +5903,6 @@ "express": ">= 4.11" } }, - "node_modules/express-rate-limit/node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/express-session": { "version": "1.18.2", "license": "MIT", @@ -5705,6 +5985,12 @@ ], "license": "MIT" }, + "node_modules/fast-copy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz", + "integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "license": "MIT" @@ -5723,6 +6009,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.0.6", "funding": [ @@ -5837,6 +6129,21 @@ "dev": true, "license": "ISC" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -5962,6 +6269,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gaxios": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", @@ -6016,6 +6352,15 @@ "is-property": "^1.0.2" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "license": "ISC", @@ -6063,6 +6408,23 @@ "node": ">= 0.4" } }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/giget": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", @@ -6119,6 +6481,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/google-logging-utils": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", @@ -6178,6 +6556,18 @@ "uglify-js": "^3.1.4" } }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-property-descriptors": { "version": "1.0.2", "license": "MIT", @@ -6188,6 +6578,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "license": "MIT", @@ -6230,10 +6635,16 @@ "node": ">=18.0.0" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "devOptional": true, "license": "MIT", "engines": { @@ -6359,13 +6770,25 @@ "version": "2.0.4", "license": "ISC" }, - "node_modules/ip-address": { - "version": "9.0.5", + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "license": "MIT", "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", "engines": { "node": ">= 12" } @@ -6377,6 +6800,85 @@ "node": ">= 0.10" } }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -6393,6 +6895,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-docker": { "version": "2.2.1", "license": "MIT", @@ -6414,6 +6949,21 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -6423,6 +6973,25 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "dev": true, @@ -6434,6 +7003,30 @@ "node": ">=0.10.0" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "license": "MIT", @@ -6441,6 +7034,22 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-promise": { "version": "4.0.0", "license": "MIT" @@ -6450,6 +7059,142 @@ "devOptional": true, "license": "MIT" }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-wsl": { "version": "2.2.0", "license": "MIT", @@ -6490,6 +7235,15 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "license": "MIT", @@ -6500,10 +7254,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "license": "MIT" - }, "node_modules/jsep": { "version": "1.4.0", "license": "MIT", @@ -7435,6 +8185,26 @@ "node": ">= 0.4" } }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -7588,6 +8358,23 @@ "node": ">= 0.8.0" } }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-limit": { "version": "3.1.0", "dev": true, @@ -7904,6 +8691,42 @@ "process-warning": "^5.0.0" } }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pino-std-serializers": { "version": "7.1.0", "license": "MIT" @@ -7920,10 +8743,19 @@ "pathe": "^2.0.3" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", "dev": true, "funding": [ { @@ -8035,9 +8867,7 @@ } }, "node_modules/prisma-json-types-generator": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/prisma-json-types-generator/-/prisma-json-types-generator-4.1.1.tgz", - "integrity": "sha512-oz0MYu1r2Jqu3qOGCEGulz6RjPsECVjyS3ZAz3HEmz0jkKbVkZEwQyksNJm0X5utV1TZCmaG/98EPExN7S22xw==", + "version": "4.1.0", "dev": true, "license": "MIT", "dependencies": { @@ -8285,10 +9115,52 @@ "node": ">= 12.13.0" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regclient-napi": { "resolved": "regclient-napi", "link": true }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/remeda": { "version": "2.33.4", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", @@ -8416,6 +9288,25 @@ "node": ">= 18" } }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "funding": [ @@ -8434,6 +9325,39 @@ ], "license": "MIT" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "license": "MIT", @@ -8453,6 +9377,22 @@ "license": "MIT", "peer": true }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.3", "license": "ISC", @@ -8515,6 +9455,35 @@ "node": ">= 0.4" } }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "license": "ISC" @@ -8636,10 +9605,12 @@ } }, "node_modules/socks": { - "version": "2.8.4", + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz", + "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==", "license": "MIT", "dependencies": { - "ip-address": "^9.0.5", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -8693,10 +9664,6 @@ "node": ">= 10.x" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "license": "BSD-3-Clause" - }, "node_modules/sqlstring": { "version": "2.3.3", "devOptional": true, @@ -8737,6 +9704,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/stream-buffers": { "version": "3.0.3", "license": "Unlicense", @@ -8769,6 +9749,62 @@ "node": ">=8" } }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -8931,6 +9967,24 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "license": "MIT", @@ -9013,6 +10067,80 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "5.9.3", "devOptional": true, @@ -9071,6 +10199,24 @@ "node": ">= 0.8" } }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici-types": { "version": "7.16.0", "license": "MIT" @@ -9384,6 +10530,91 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/backend/package.json b/backend/package.json index 5499d3e6..6496c89e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,7 @@ "license": "UNLICENSED", "description": "", "dependencies": { + "@fishballpkg/acme": "npm:@jsr/fishballpkg__acme@^0.15.0", "@kubernetes/client-node": "^1.4.0", "@octokit/webhooks": "^14.1.3", "@opentelemetry/api": "^1.9.0", @@ -33,6 +34,7 @@ "body-parser": "^2.2.1", "connect-pg-simple": "^10.0.0", "cookie-parser": "^1.4.7", + "es-arraybuffer-base64": "^1.1.2", "express": "^5.1.0", "express-rate-limit": "^8.2.1", "express-session": "^1.18.2", @@ -45,7 +47,9 @@ "pg": "^8.16.3", "pino": "^10.1.1", "pino-http": "^11.0.0", - "regclient-napi": "file:./regclient-napi" + "pino-pretty": "^13.1.3", + "regclient-napi": "file:./regclient-napi", + "tldts": "^7.0.28" }, "devDependencies": { "@electric-sql/pglite": "^0.4.1", diff --git a/backend/patches/@fishballpkg+acme+0.15.0.patch b/backend/patches/@fishballpkg+acme+0.15.0.patch new file mode 100644 index 00000000..098f4d8f --- /dev/null +++ b/backend/patches/@fishballpkg+acme+0.15.0.patch @@ -0,0 +1,112 @@ +diff --git a/node_modules/@fishballpkg/acme/_dist/src/AcmeChallenge.d.ts b/node_modules/@fishballpkg/acme/_dist/src/AcmeChallenge.d.ts +index 43b2f48..6b2587b 100755 +--- a/node_modules/@fishballpkg/acme/_dist/src/AcmeChallenge.d.ts ++++ b/node_modules/@fishballpkg/acme/_dist/src/AcmeChallenge.d.ts +@@ -63,6 +63,7 @@ import type { AcmeAuthorization } from "./AcmeAuthorization.js"; + /** + * Fetches a snapshot of the challenge object from the Certificate Authority (CA). + */ fetch(): Promise; ++ keyAuthorization(): Promise; + /** + * Produces the key authorization for this challenge by digesting the challenge token. + */ digestToken(): Promise; +diff --git a/node_modules/@fishballpkg/acme/_dist/src/AcmeClient.d.ts b/node_modules/@fishballpkg/acme/_dist/src/AcmeClient.d.ts +index 81dff96..463314e 100755 +--- a/node_modules/@fishballpkg/acme/_dist/src/AcmeClient.d.ts ++++ b/node_modules/@fishballpkg/acme/_dist/src/AcmeClient.d.ts +@@ -1,3 +1,4 @@ ++import type { webcrypto } from "node:crypto"; // AnvilOps patch: fix type error + import { AcmeAccount } from "./AcmeAccount.js"; + /** + * The directory object. +@@ -40,7 +41,7 @@ import { AcmeAccount } from "./AcmeAccount.js"; + /** + * Fetch a url where the data are signed with the provided private key using JSON Web Signature (JWS). + */ jwsFetch(url: string, {}: { +- privateKey: CryptoKey; ++ privateKey: webcrypto.CryptoKey; // AnvilOps patch: fix type error + protected: Record; + payload?: Record; + retryAttemptCount?: number; +diff --git a/node_modules/@fishballpkg/acme/src/AcmeChallenge.js b/node_modules/@fishballpkg/acme/src/AcmeChallenge.js +index 054b806..f8cc822 100755 +--- a/node_modules/@fishballpkg/acme/src/AcmeChallenge.js ++++ b/node_modules/@fishballpkg/acme/src/AcmeChallenge.js +@@ -38,11 +38,14 @@ import { encodeBase64Url } from "./utils/encoding.js"; + const response = await this.#account.jwsFetch(this.url); + return await response.json(); + } ++ async keyAuthorization() { ++ const publicKeyJwk = await crypto.subtle.exportKey("jwk", this.#account.keyPair.publicKey); ++ return `${this.token}.${await getJWKThumbprint(publicKeyJwk)}`; ++ } + /** + * Produces the key authorization for this challenge by digesting the challenge token. + */ async digestToken() { +- const publicKeyJwk = await crypto.subtle.exportKey("jwk", this.#account.keyPair.publicKey); +- return encodeBase64Url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${this.token}.${await getJWKThumbprint(publicKeyJwk)}`))); ++ return encodeBase64Url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(await this.keyAuthorization()))); + } + /** + * Submit the challenge. +diff --git a/node_modules/@fishballpkg/acme/src/AcmeChallenge.ts b/node_modules/@fishballpkg/acme/src/AcmeChallenge.ts +index 2fe7b5a..cc12af2 100755 +--- a/node_modules/@fishballpkg/acme/src/AcmeChallenge.ts ++++ b/node_modules/@fishballpkg/acme/src/AcmeChallenge.ts +@@ -106,6 +106,11 @@ export class AcmeChallenge { + return await response.json(); + } + ++ async keyAuthorization(): Promise { ++ const publicKeyJwk = await crypto.subtle.exportKey("jwk", this.#account.keyPair.publicKey); ++ return `${this.token}.${await getJWKThumbprint(publicKeyJwk)}`; ++ } ++ + /** + * Produces the key authorization for this challenge by digesting the challenge token. + */ +@@ -153,7 +158,7 @@ export class AcmeChallenge { + } + } + +-async function getJWKThumbprint(jwk: JsonWebKey): Promise { ++export async function getJWKThumbprint(jwk: JsonWebKey): Promise { + // Step 1: Create the canonical JSON string from required JWK fields, + const canonicalJWK = JSON.stringify({ + crv: jwk.crv, +diff --git a/node_modules/@fishballpkg/acme/src/utils/encoding.js b/node_modules/@fishballpkg/acme/src/utils/encoding.js +index 85bf5c4..fd8412f 100755 +--- a/node_modules/@fishballpkg/acme/src/utils/encoding.js ++++ b/node_modules/@fishballpkg/acme/src/utils/encoding.js +@@ -1,16 +1,20 @@ ++// AnvilOps patch: we're using a polyfill here. This method was added to Node in version 25, which will go out of support soon, and 26 isn't out yet. ++// This patch can be removed after upgrading to Node.js 26 or later. ++import toBase64 from "es-arraybuffer-base64/Uint8Array.prototype.toBase64"; ++ + const toUint8Array = (input)=>{ + if (input instanceof Uint8Array) return input; + if (input instanceof ArrayBuffer) return new Uint8Array(input); + return new TextEncoder().encode(input); + }; + export const encodeBase64Url = (input)=>{ +- return toUint8Array(input).toBase64({ ++ return toBase64(toUint8Array(input), { + alphabet: "base64url", + omitPadding: true + }); + }; + export const encodeBase64 = (input)=>{ +- return toUint8Array(input).toBase64({ ++ return toBase64(toUint8Array(input), { + alphabet: "base64" + }); + }; +diff --git a/node_modules/@fishballpkg/acme/src/utils/pem.js b/node_modules/@fishballpkg/acme/src/utils/pem.js +index bdf0dd0..11adf20 100755 +--- a/node_modules/@fishballpkg/acme/src/utils/pem.js ++++ b/node_modules/@fishballpkg/acme/src/utils/pem.js +@@ -1,2 +1,2 @@ +-export const extractFirstPemObject = (pem)=>Uint8Array.fromBase64(pem.replace(/-----BEGIN [^-]+-----/, "").replace(/-----END [^-]+-----.*$/s, "").replaceAll(/\s/g, "")); ++export const extractFirstPemObject = (pem)=>Uint8Array.from(Buffer.from(pem.replace(/-----BEGIN [^-]+-----/, "").replace(/-----END [^-]+-----.*$/s, "").replaceAll(/\s/g, ""), "base64")); + //# sourceMappingURL=pem.js.map +\ No newline at end of file diff --git a/backend/prisma/migrations/20260502235317_add_custom_domain_model/migration.sql b/backend/prisma/migrations/20260502235317_add_custom_domain_model/migration.sql new file mode 100644 index 00000000..d222b8ce --- /dev/null +++ b/backend/prisma/migrations/20260502235317_add_custom_domain_model/migration.sql @@ -0,0 +1,25 @@ +-- CreateEnum +CREATE TYPE "DomainVerificationStatus" AS ENUM ('UNVERIFIED', 'PENDING', 'GENERATING', 'GENERATED', 'ERROR'); + +-- CreateTable +CREATE TABLE "Domain" ( + "id" SERIAL NOT NULL, + "appId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "status" "DomainVerificationStatus" NOT NULL DEFAULT 'UNVERIFIED', + "certIssuedAt" TIMESTAMP(3), + "certExpiresAt" TIMESTAMP(3), + "verificationToken" TEXT NOT NULL, + "orderURL" TEXT, + "token" TEXT, + "keyAuthorization" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Domain_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Domain_token_key" ON "Domain"("token"); + +-- AddForeignKey +ALTER TABLE "Domain" ADD CONSTRAINT "Domain_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260503154418_add_cache_encryption_key_column/migration.sql b/backend/prisma/migrations/20260503154418_add_cache_encryption_key_column/migration.sql new file mode 100644 index 00000000..3c5790bc --- /dev/null +++ b/backend/prisma/migrations/20260503154418_add_cache_encryption_key_column/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cache" ADD COLUMN "encryptionKey" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 51613b1b..75ed930a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -163,6 +163,7 @@ model App { imageRepo String @default("") deployments Deployment[] logIngestSecret String @unique + domains Domain[] // The config to use when creating a new deployment config DeploymentConfig? @relation(fields: [configId], references: [id]) configId Int? @unique @@ -174,6 +175,36 @@ model App { enableCD Boolean @default(true) } +model Domain { + id Int @id @default(autoincrement()) + app App @relation(fields: [appId], references: [id], onDelete: Cascade) + appId Int + name String + status DomainVerificationStatus @default(UNVERIFIED) + certIssuedAt DateTime? + certExpiresAt DateTime? + // Should match a TXT record on the custom domain + verificationToken String @default(ulid()) + // Fields used during verification + orderURL String? + token String? @unique + keyAuthorization String? + updatedAt DateTime @updatedAt +} + +enum DomainVerificationStatus { + // The domain hasn't been verified, so the certificate hasn't been requested yet. + UNVERIFIED + // The challenge has been created, but the ACME server hasn't sent us a request yet. + PENDING + // The ACME server has requested the challenge URL, but the certificate hasn't been created yet. + GENERATING + // A certificate has successfully been generated for the domain. + GENERATED + // There was a problem verifying the domain. The process should be restarted. + ERROR +} + model AppGroup { id Int @id @default(autoincrement()) orgId Int @@ -302,9 +333,10 @@ model RepoImportState { // Used to cache things like GitHub API responses so that we don't have to fetch them every time. // This reduces load on the upstream and makes the site faster. model Cache { - key String @id - value String - expiresAt DateTime + key String @id + value String + expiresAt DateTime + encryptionKey String? } enum WebhookEvent { diff --git a/backend/src/db/crypto.ts b/backend/src/db/crypto.ts index d89ccb05..9031099d 100644 --- a/backend/src/db/crypto.ts +++ b/backend/src/db/crypto.ts @@ -63,4 +63,20 @@ export const decryptEnv = ( })); }; +export const encryptCachedValue = ( + masterKey: Buffer, + plaintext: string, + key: string, +) => { + return encrypt(plaintext, unwrapKey(masterKey, key)); +}; + +export const decryptCachedValue = ( + masterKey: Buffer, + ciphertext: string, + key: string, +) => { + return decrypt(ciphertext, unwrapKey(masterKey, key)); +}; + export const generateKey = (masterKey: Buffer) => wrapKey(masterKey, genKey()); diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 1437b224..743c64b4 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -8,7 +8,9 @@ import { AppRepo } from "./repo/app.ts"; import { AppGroupRepo } from "./repo/appGroup.ts"; import { CacheRepo } from "./repo/cache.ts"; import { DeploymentRepo } from "./repo/deployment.ts"; +import { DomainRepo } from "./repo/domain.ts"; import { InvitationRepo } from "./repo/invitation.ts"; +import { LogRepo } from "./repo/logs.ts"; import { OrganizationRepo } from "./repo/organization.ts"; import { RepoImportStateRepo } from "./repo/repoImportState.ts"; import { UserRepo } from "./repo/user.ts"; @@ -32,6 +34,8 @@ export abstract class Database { abstract org: OrganizationRepo; abstract repoImportState: RepoImportStateRepo; abstract user: UserRepo; + abstract domain: DomainRepo; + abstract log: LogRepo; abstract sessionStore: session.Store; abstract subscribe( channel: string, @@ -53,13 +57,15 @@ export class PrismaDatabase extends Database { org: OrganizationRepo; repoImportState: RepoImportStateRepo; user: UserRepo; + domain: DomainRepo; + log: LogRepo; sessionStore: session.Store; constructor(client: PrismaClientType, masterKey: Buffer) { super(); this.client = client; this.appGroup = new AppGroupRepo(this.client); - this.cache = new CacheRepo(this.client); + this.cache = new CacheRepo(this.client, masterKey); this.deployment = new DeploymentRepo( this.client, this.publish.bind(this), @@ -70,6 +76,8 @@ export class PrismaDatabase extends Database { this.org = new OrganizationRepo(this.client); this.repoImportState = new RepoImportStateRepo(this.client); this.user = new UserRepo(this.client); + this.domain = new DomainRepo(this.client); + this.log = new LogRepo(this.client); } /* eslint-disable @typescript-eslint/no-unused-vars */ diff --git a/backend/src/db/models.ts b/backend/src/db/models.ts index 8b4320e5..752010b3 100644 --- a/backend/src/db/models.ts +++ b/backend/src/db/models.ts @@ -1,6 +1,7 @@ import type { DeploymentSource, DeploymentStatus, + DomainVerificationStatus, GitHubOAuthAction, HelmUrlType, ImageBuilder, @@ -238,3 +239,17 @@ export interface GitHubOAuthState { orgId: number; action: GitHubOAuthAction; } + +export interface Domain { + id: number; + appId: number; + name: string; + status: DomainVerificationStatus; + certIssuedAt: Date | null; + certExpiresAt: Date | null; + verificationToken: string; + orderURL: string | null; + token: string | null; + keyAuthorization: string | null; + updatedAt: Date; +} diff --git a/backend/src/db/repo/cache.ts b/backend/src/db/repo/cache.ts index 8323d1c3..ae293582 100644 --- a/backend/src/db/repo/cache.ts +++ b/backend/src/db/repo/cache.ts @@ -1,10 +1,17 @@ +import { + decryptCachedValue, + encryptCachedValue, + generateKey, +} from "../crypto.ts"; import type { PrismaClientType } from "../index.ts"; export class CacheRepo { private client: PrismaClientType; + private masterKey: Buffer; - constructor(client: PrismaClientType) { + constructor(client: PrismaClientType, masterKey: Buffer) { this.client = client; + this.masterKey = masterKey; } async clean() { @@ -13,6 +20,10 @@ export class CacheRepo { }); } + async remove(key: string) { + await this.client.cache.delete({ where: { key } }); + } + async get(key: string): Promise { return ( await this.client.cache.findUnique({ @@ -29,4 +40,24 @@ export class CacheRepo { update: { key, value }, }); } + + async getEncrypted(key: string) { + const row = await this.client.cache.findUnique({ + where: { key, expiresAt: { gt: new Date() } }, + }); + if (!row.encryptionKey) { + throw new Error("getEncrypted called on a row without an encryption key"); + } + return decryptCachedValue(this.masterKey, row.value, row.encryptionKey); + } + + async setEncrypted(cacheKey: string, value: string, expiresAt: Date) { + const encryptionKey = generateKey(this.masterKey); + const enc = encryptCachedValue(this.masterKey, value, encryptionKey); + await this.client.cache.upsert({ + where: { key: cacheKey }, + create: { key: cacheKey, encryptionKey, value: enc, expiresAt }, + update: { key: cacheKey, encryptionKey, value: enc }, + }); + } } diff --git a/backend/src/db/repo/domain.ts b/backend/src/db/repo/domain.ts new file mode 100644 index 00000000..4b568956 --- /dev/null +++ b/backend/src/db/repo/domain.ts @@ -0,0 +1,167 @@ +import type { + DomainVerificationStatus, + PermissionLevel, +} from "../../generated/prisma/enums.ts"; +import type { PrismaClientType } from "../index.ts"; + +export class DomainRepo { + private client: PrismaClientType; + + constructor(client: PrismaClientType) { + this.client = client; + } + + async create(appId: number, domainName: string) { + await this.client.domain.create({ + data: { + appId, + name: domainName, + }, + }); + } + + async delete( + domainId: number, + options?: { + requireUser?: { id: number; permissionLevel?: PermissionLevel }; + }, + ) { + await this.client.domain.delete({ + where: { + id: domainId, + ...(options?.requireUser + ? { + app: { + org: { + users: { + some: { + userId: options.requireUser.id, + permissionLevel: options.requireUser.permissionLevel, + }, + }, + }, + }, + } + : {}), + }, + }); + } + + async getById( + domainId: number, + options?: { + requireUser?: { id: number; permissionLevel?: PermissionLevel }; + }, + ) { + return await this.client.domain.findUnique({ + where: { + id: domainId, + ...(options?.requireUser + ? { + app: { + org: { + users: { + some: { + userId: options.requireUser.id, + permissionLevel: options.requireUser.permissionLevel, + }, + }, + }, + }, + } + : {}), + }, + }); + } + + async getByToken(token: string) { + return await this.client.domain.findUnique({ where: { token } }); + } + + async getByName(domainName: string) { + return await this.client.domain.findMany({ where: { name: domainName } }); + } + + async listByAppId( + appId: number, + options?: { + requireUser?: { id: number; permissionLevel?: PermissionLevel }; + }, + ) { + return await this.client.domain.findMany({ + where: { + appId, + ...(options?.requireUser + ? { + app: { + org: { + users: { + some: { + userId: options.requireUser.id, + permissionLevel: options.requireUser.permissionLevel, + }, + }, + }, + }, + } + : {}), + }, + }); + } + + async updateCertOrderDetails( + domainId: number, + challengeToken: string, + keyAuthorization: string, + orderURL: string, + ) { + await this.client.domain.update({ + where: { id: domainId }, + data: { + token: challengeToken, + keyAuthorization, + orderURL, + }, + }); + } + + async setStatus(domainId: number, status: DomainVerificationStatus) { + await this.client.domain.update({ + where: { id: domainId }, + data: { status }, + }); + } + + async markAsGenerated(domainId: number, notBefore: Date, notAfter: Date) { + await this.client.domain.update({ + where: { id: domainId }, + data: { + token: null, + keyAuthorization: null, + orderURL: null, + status: "GENERATED", + certIssuedAt: notBefore, + certExpiresAt: notAfter, + }, + }); + } + + /** + * Returns verified domains that have certificates that have less than 1/3 of their validity window remaining. + * These certificates should be renewed soon. + */ + async listUpForRenewal() { + const maxDate = new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 7); // 7 days from now + const domains = await this.client.domain.findMany({ + where: { + status: { in: ["ERROR", "GENERATED", "PENDING", "GENERATING"] }, + certExpiresAt: { lt: maxDate }, + }, + }); + return domains.filter((it) => { + const iss = it.certIssuedAt.getTime(); + const exp = it.certExpiresAt.getTime(); + return (exp - new Date().getTime()) / (exp - iss) <= 0.333; + }); + } +} diff --git a/backend/src/db/repo/logs.ts b/backend/src/db/repo/logs.ts new file mode 100644 index 00000000..acb9749f --- /dev/null +++ b/backend/src/db/repo/logs.ts @@ -0,0 +1,19 @@ +import type { PrismaClientType } from "../index.ts"; + +export class LogRepo { + private client: PrismaClientType; + + constructor(client: PrismaClientType) { + this.client = client; + } + + async deleteLogsOlderThan(date: Date) { + return await this.client.log.deleteMany({ + where: { + timestamp: { + lt: date, + }, + }, + }); + } +} diff --git a/backend/src/handlers/acmeChallenge.ts b/backend/src/handlers/acmeChallenge.ts new file mode 100644 index 00000000..b1a87b0b --- /dev/null +++ b/backend/src/handlers/acmeChallenge.ts @@ -0,0 +1,26 @@ +import { + DomainNotFoundError, + ValidationError, +} from "../service/errors/index.ts"; +import { certGenerationService } from "../service/index.ts"; +import { unsafeGenericResponse, type HandlerMap } from "../types.ts"; + +export const handleAcmeChallengeHandler: HandlerMap["acmeChallenge"] = async ( + ctx, + req, + res, +) => { + try { + const keyAuthorization = await certGenerationService.handleAcmeChallenge( + ctx.request.params.token, + ); + return unsafeGenericResponse(res.send(keyAuthorization).end()); + } catch (e) { + if (e instanceof DomainNotFoundError) { + return unsafeGenericResponse(res.send("Domain not found").end()); + } else if (e instanceof ValidationError) { + return unsafeGenericResponse(res.send(e.message).end()); + } + throw e; + } +}; diff --git a/backend/src/handlers/addDomain.ts b/backend/src/handlers/addDomain.ts new file mode 100644 index 00000000..d99c53d8 --- /dev/null +++ b/backend/src/handlers/addDomain.ts @@ -0,0 +1,31 @@ +import { + PrivateSuffixError, + ValidationError, +} from "../service/errors/index.ts"; +import { addDomainService } from "../service/index.ts"; +import { empty, json, type HandlerMap } from "../types.ts"; + +export const addDomainHandler: HandlerMap["addDomain"] = async ( + ctx, + req, + res, +) => { + try { + await addDomainService.addDomain( + ctx.request.params.appId, + ctx.request.requestBody.name, + ); + } catch (e) { + if (e instanceof ValidationError) { + return json(400, res, { code: 400, message: e.message }); + } else if (e instanceof PrivateSuffixError) { + return json(400, res, { + code: 400, + message: + "This domain uses a private suffix. To verify a domain, you must own its base name.", + }); + } + throw e; + } + return empty(201, res); +}; diff --git a/backend/src/handlers/index.ts b/backend/src/handlers/index.ts index e7306984..50720b16 100644 --- a/backend/src/handlers/index.ts +++ b/backend/src/handlers/index.ts @@ -1,6 +1,8 @@ import { type Request as ExpressRequest } from "express"; import { type HandlerMap } from "../types.ts"; import { acceptInvitationHandler } from "./acceptInvitation.ts"; +import { handleAcmeChallengeHandler } from "./acmeChallenge.ts"; +import { addDomainHandler } from "./addDomain.ts"; import { claimOrgHandler } from "./claimOrg.ts"; import { createAppHandler } from "./createApp.ts"; import { createAppGroupHandler } from "./createAppGroup.ts"; @@ -37,16 +39,20 @@ import { isNamespaceAvailableHandler } from "./isNamespaceAvailable.ts"; import { isSubdomainAvailableHandler } from "./isSubdomainAvailable.ts"; import { listChartsHandler } from "./listCharts.ts"; import { listDeploymentsHandler } from "./listDeployments.ts"; +import { listDomainsHandler } from "./listDomains.ts"; import { listOrgGroupsHandler } from "./listOrgGroups.ts"; import { listOrgReposHandler } from "./listOrgRepos.ts"; import { listRepoBranchesHandler } from "./listRepoBranches.ts"; import { listRepoWorkflowsHandler } from "./listRepoWorkflows.ts"; import { livenessProbe } from "./liveness.ts"; +import { removeDomainHandler } from "./removeDomain.ts"; import { removeUserFromOrgHandler } from "./removeUserFromOrg.ts"; +import { retryCertGenHandler } from "./retryCertGen.ts"; import { revokeInvitationHandler } from "./revokeInvitation.ts"; import { setAppCDHandler } from "./setAppCD.ts"; import { updateAppHandler } from "./updateApp.ts"; import { updateDeploymentHandler } from "./updateDeployment.ts"; +import { verifyDomainHandler } from "./verifyDomain.ts"; export type AuthenticatedRequest = ExpressRequest & { user: { @@ -58,6 +64,8 @@ export type AuthenticatedRequest = ExpressRequest & { export const handlers = { acceptInvitation: acceptInvitationHandler, + acmeChallenge: handleAcmeChallengeHandler, + addDomain: addDomainHandler, claimOrg: claimOrgHandler, createApp: createAppHandler, createAppGroup: createAppGroupHandler, @@ -89,15 +97,19 @@ export const handlers = { isNamespaceAvailable: isNamespaceAvailableHandler, listCharts: listChartsHandler, listDeployments: listDeploymentsHandler, + listDomains: listDomainsHandler, listOrgGroups: listOrgGroupsHandler, listOrgRepos: listOrgReposHandler, listRepoBranches: listRepoBranchesHandler, listRepoWorkflows: listRepoWorkflowsHandler, livenessProbe, + removeDomain: removeDomainHandler, removeUserFromOrg: removeUserFromOrgHandler, + retryCertGen: retryCertGenHandler, revokeInvitation: revokeInvitationHandler, setAppCD: setAppCDHandler, updateApp: updateAppHandler, updateDeployment: updateDeploymentHandler, + verifyDomain: verifyDomainHandler, writeAppFile: writeAppFileHandler, } as const satisfies HandlerMap; diff --git a/backend/src/handlers/listDomains.ts b/backend/src/handlers/listDomains.ts new file mode 100644 index 00000000..cc89b26a --- /dev/null +++ b/backend/src/handlers/listDomains.ts @@ -0,0 +1,16 @@ +import { listDomainsService } from "../service/index.ts"; +import { json, type HandlerMap } from "../types.ts"; +import type { AuthenticatedRequest } from "./index.ts"; + +export const listDomainsHandler: HandlerMap["listDomains"] = async ( + ctx, + req: AuthenticatedRequest, + res, +) => { + return json(200, res, { + domains: await listDomainsService.listDomains( + ctx.request.params.appId, + req.user.id, + ), + }); +}; diff --git a/backend/src/handlers/removeDomain.ts b/backend/src/handlers/removeDomain.ts new file mode 100644 index 00000000..0aaf58ea --- /dev/null +++ b/backend/src/handlers/removeDomain.ts @@ -0,0 +1,24 @@ +import { DomainNotFoundError } from "../service/errors/index.ts"; +import { removeDomainService } from "../service/index.ts"; +import { empty, type HandlerMap } from "../types.ts"; + +export const removeDomainHandler: HandlerMap["removeDomain"] = async ( + ctx, + req, + res, +) => { + try { + await removeDomainService.removeDomain( + ctx.request.params.domainId, + req.user.id, + ); + } catch (e) { + if (e instanceof DomainNotFoundError) { + return empty(404, res); + } + + throw e; + } + + return empty(204, res); +}; diff --git a/backend/src/handlers/retryCertGen.ts b/backend/src/handlers/retryCertGen.ts new file mode 100644 index 00000000..206b075f --- /dev/null +++ b/backend/src/handlers/retryCertGen.ts @@ -0,0 +1,22 @@ +import { ValidationError } from "../service/errors/index.ts"; +import { retryCertGenService } from "../service/index.ts"; +import { empty, json, type HandlerMap } from "../types.ts"; + +export const retryCertGenHandler: HandlerMap["retryCertGen"] = async ( + ctx, + req, + res, +) => { + try { + await retryCertGenService.retryCertGen( + req.user.id, + ctx.request.params.domainId, + ); + } catch (e) { + if (e instanceof ValidationError) { + return json(400, res, { code: 400, message: e.message }); + } + throw e; + } + return empty(200, res); +}; diff --git a/backend/src/handlers/verifyDomain.ts b/backend/src/handlers/verifyDomain.ts new file mode 100644 index 00000000..3a345111 --- /dev/null +++ b/backend/src/handlers/verifyDomain.ts @@ -0,0 +1,28 @@ +import { ValidationError } from "../service/errors/index.ts"; +import { verifyDomainService } from "../service/index.ts"; +import { empty, json, type HandlerMap } from "../types.ts"; +import type { AuthenticatedRequest } from "./index.ts"; + +export const verifyDomainHandler: HandlerMap["verifyDomain"] = async ( + ctx, + req: AuthenticatedRequest, + res, +) => { + try { + await verifyDomainService.verifyDomain( + ctx.request.params.domainId, + req.user.id, + ); + } catch (e) { + if (e instanceof ValidationError) { + return json(400, res, { + code: 400, + message: + "Couldn't find the expected DNS records. Make sure they are correct and try again in a few minutes.", + }); + } + throw e; + } + + return empty(200, res); +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index 041c0242..dad4ec05 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -17,6 +17,16 @@ import { db, getSettingsService } from "./service/index.ts"; const app = express(); const port = process.env.PORT ?? 3000; +app.use((req, res, next) => { + if (req.url.startsWith("/.well-known/acme-challenge/")) { + // These requests are for HTTP-01 certificate generation challenges. + // They need to be at /.well-known/..., but the route is defined in the OpenAPI + // spec as /api/.well-known/..., so here we just rewrite the former to the latter. + req.url = "/api" + req.url; + } + next(); +}); + app.use((req, res, next) => { const traceId = trace.getActiveSpan()?.spanContext()?.traceId; if (traceId) { diff --git a/backend/src/jobs/renewCertificates.ts b/backend/src/jobs/renewCertificates.ts new file mode 100644 index 00000000..57fdd854 --- /dev/null +++ b/backend/src/jobs/renewCertificates.ts @@ -0,0 +1,68 @@ +import { setTimeout } from "node:timers/promises"; +import { PgDatabase, type Database } from "../db/index.ts"; +import { logger } from "../logger.ts"; +import { CertGenerationService } from "../service/certGeneration.ts"; +import { KVCacheService } from "../service/common/cache.ts"; +import { KubernetesClientService } from "../service/common/cluster/kubernetes.ts"; +import { RancherService } from "../service/common/cluster/rancher.ts"; +import { IngressConfigService } from "../service/common/cluster/resources/ingress.ts"; + +const db: Database = new PgDatabase( + process.env.DATABASE_URL ?? + `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOSTNAME}/${process.env.POSTGRES_DB}`, + Buffer.from(process.env.FIELD_ENCRYPTION_KEY, "base64"), +); + +const rancherService = new RancherService( + process.env.RANCHER_TOKEN, + process.env.RANCHER_BASE_URL, + process.env.LOGIN_TYPE, + process.env.SANDBOX_ID, +); + +const kubernetesClientService = new KubernetesClientService( + db.user, + rancherService, + process.env.CURRENT_NAMESPACE, +); + +const ingressConfigService = new IngressConfigService( + kubernetesClientService, + process.env.APP_DOMAIN, + process.env.INGRESS_CLASS_NAME, + process.env.CURRENT_NAMESPACE, +); + +const cacheService = new KVCacheService(db.cache); + +const certGenService = new CertGenerationService( + cacheService, + db.app, + db.domain, + ingressConfigService, + kubernetesClientService, + process.env.ACME_SERVER_ADDRESS, +); + +/** + * This job renews custom domains' TLS certificates that are about to expire. + */ +async function renewCertificates() { + const domains = await db.domain.listUpForRenewal(); + logger.info({ domainCount: domains.length }, "Renewing domains"); + for (let i = 0; i < domains.length; i++) { + const domain = domains[i]; + /* eslint-disable no-await-in-loop */ + await db.domain.setStatus(domain.id, "PENDING"); + await certGenService.generateCert(domain.id); + + const isLast = i == domains.length - 1; + if (!isLast) { + // Wait 5 seconds between domains just in case the ACME endpoint has a rate limit + await setTimeout(5000); + } + /* eslint-enable no-await-in-loop */ + } +} + +await renewCertificates(); diff --git a/backend/src/jobs/rotateLogs.ts b/backend/src/jobs/rotateLogs.ts index d33afc0b..b7983c0d 100644 --- a/backend/src/jobs/rotateLogs.ts +++ b/backend/src/jobs/rotateLogs.ts @@ -1,30 +1,22 @@ /* eslint-disable no-console */ -import { PrismaPg } from "@prisma/adapter-pg"; -import { PrismaClient } from "../generated/prisma/client.ts"; +import { PgDatabase, type Database } from "../db/index.ts"; const MS_PER_DAY = 86_400_000; const RETENTION_PERIOD_MS = 14 * MS_PER_DAY; +const db: Database = new PgDatabase( + process.env.DATABASE_URL ?? + `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOSTNAME}/${process.env.POSTGRES_DB}`, + Buffer.from(process.env.FIELD_ENCRYPTION_KEY, "base64"), +); + /** * Deletes logs that have a timestamp of over 14 days ago. * This function is run in a CronJob (see charts/anvilops/templates/jobs/rotate-logs.yaml) */ async function rotateLogs() { - const connectionString = `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOSTNAME}/${process.env.POSTGRES_DB}`; - - const db = new PrismaClient({ - // Note: We can't import `db` from db/index.ts because of side effects. db/index.ts expects some environment variables that aren't present when we run this job. - adapter: new PrismaPg({ connectionString }), - }); - const minDate = new Date(Date.now() - RETENTION_PERIOD_MS); - const result = await db.log.deleteMany({ - where: { - timestamp: { - lt: minDate, - }, - }, - }); + const result = await db.log.deleteLogsOlderThan(minDate); console.log( `Deleted ${result.count} log entries older than ${minDate.toLocaleString()}.`, ); diff --git a/backend/src/lib/env.ts b/backend/src/lib/env.ts index 1e633a09..5462a3a4 100644 --- a/backend/src/lib/env.ts +++ b/backend/src/lib/env.ts @@ -1,3 +1,5 @@ +import { ACME_DIRECTORY_URLS } from "@fishballpkg/acme"; + type EnvVarDefinition = | { required: true; @@ -65,6 +67,20 @@ const variables = { * If this variable is not specified, subdomains will not be shown to users. */ APP_DOMAIN: { required: false }, + /** + * The hostname that AnvilOps recommends users set as a CNAME on their custom domains to point them to AnvilOps. + * Must resolve to an address of the K8s ingress controller. + */ + CNAME_DOMAIN: { required: false }, + /** + * The URL of an ACME server (https://datatracker.ietf.org/doc/html/rfc8555), + * used to generate certificates for custom domains. Defaults to the Let's Encrypt staging endpoint, + * which isn't suitable for production and isn't trusted by browsers. + */ + ACME_SERVER_ADDRESS: { + required: false, + defaultValue: ACME_DIRECTORY_URLS.LETS_ENCRYPT_STAGING, + }, /** * Path to the JSON file describing the cluster on which AnvilOps is installed. */ diff --git a/backend/src/logger.ts b/backend/src/logger.ts index ed264202..dae08db6 100644 --- a/backend/src/logger.ts +++ b/backend/src/logger.ts @@ -1,3 +1,6 @@ import pino from "pino"; +import pretty from "pino-pretty"; -export const logger = pino(); +export const logger = process.env.IN_TILT + ? pino(pretty({ ignore: "req.headers,res.headers", colorize: true })) // In development, pretty-print log lines + : pino(); // Otherwise, use the default JSON lines format diff --git a/backend/src/server/auth.ts b/backend/src/server/auth.ts index 30b482ac..680d1e2a 100644 --- a/backend/src/server/auth.ts +++ b/backend/src/server/auth.ts @@ -70,6 +70,7 @@ export const ALLOWED_ANONYMOUS_ROUTES = [ "/logs/ingest", "/settings", "/templates", + "/.well-known/acme-challenge/", ]; export const ALLOWED_ANONYMOUS_OPERATIONS: (keyof operations)[] = [ @@ -80,6 +81,7 @@ export const ALLOWED_ANONYMOUS_OPERATIONS: (keyof operations)[] = [ "ingestLogs", "getSettings", "getTemplates", + "acmeChallenge", ]; router.use((req, res, next) => { diff --git a/backend/src/service/addDomain.ts b/backend/src/service/addDomain.ts new file mode 100644 index 00000000..a0776b78 --- /dev/null +++ b/backend/src/service/addDomain.ts @@ -0,0 +1,20 @@ +import type { DomainRepo } from "../db/repo/domain.ts"; +import type { CustomDomainService } from "./customDomain.ts"; + +export class AddDomainService { + private domainRepo: DomainRepo; + private customDomainService: CustomDomainService; + + constructor( + domainRepo: DomainRepo, + customDomainService: CustomDomainService, + ) { + this.domainRepo = domainRepo; + this.customDomainService = customDomainService; + } + + async addDomain(appId: number, domainName: string) { + this.customDomainService.validateDomainName(domainName); + return await this.domainRepo.create(appId, domainName); + } +} diff --git a/backend/src/service/certGeneration.ts b/backend/src/service/certGeneration.ts new file mode 100644 index 00000000..ae83bce2 --- /dev/null +++ b/backend/src/service/certGeneration.ts @@ -0,0 +1,274 @@ +import { + AccountDoesNotExistError, + AcmeClient, + AcmeOrder, + CertUtils, + type AcmeAccount, +} from "@fishballpkg/acme"; +import type { V1Secret } from "@kubernetes/client-node"; +import type { webcrypto } from "node:crypto"; +import type { AppRepo } from "../db/repo/app.ts"; +import type { DomainRepo } from "../db/repo/domain.ts"; +import { logger } from "../logger.ts"; +import type { KVCacheService } from "./common/cache.ts"; +import type { KubernetesClientService } from "./common/cluster/kubernetes.ts"; +import { + createNamespaceConfig, + type K8sObject, +} from "./common/cluster/resources.ts"; +import type { IngressConfigService } from "./common/cluster/resources/ingress.ts"; +import { DomainNotFoundError, ValidationError } from "./errors/index.ts"; + +export class CertGenerationService { + private cacheService: KVCacheService; + private client: AcmeClient; + private acmeServer: string; + private appRepo: AppRepo; + private ingressService: IngressConfigService; + private k8sService: KubernetesClientService; + private domainRepo: DomainRepo; + + constructor( + cacheService: KVCacheService, + appRepo: AppRepo, + domainRepo: DomainRepo, + ingressService: IngressConfigService, + k8sService: KubernetesClientService, + acmeServer: string, + ) { + this.cacheService = cacheService; + this.appRepo = appRepo; + this.domainRepo = domainRepo; + this.ingressService = ingressService; + this.k8sService = k8sService; + this.acmeServer = acmeServer; + } + + private async getClient() { + if (!this.client) { + this.client = await AcmeClient.init(this.acmeServer); + } + return this.client; + } + + private async login(isRetry: boolean = false): Promise { + const client = await this.getClient(); + const cacheKey = + "acme-account-" + + Buffer.from( + await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(JSON.stringify(client.directory)), + ), + ).toString("hex"); + + type SerializedAccount = { + publicKey: webcrypto.JsonWebKey; + privateKey: webcrypto.JsonWebKey; + }; + + const accountStr = await this.cacheService.getOrCreate( + cacheKey, + 60 * 60 * 24 * 365 * 10, // 10 years - we should never have to create more than one account + async () => { + const account = await client.createAccount({ emails: [] }); + const privateKey = await crypto.subtle.exportKey( + "jwk", + account.keyPair.privateKey, + ); + const publicKey = await crypto.subtle.exportKey( + "jwk", + account.keyPair.publicKey, + ); + return JSON.stringify({ + privateKey, + publicKey, + } satisfies SerializedAccount); + }, + /* encrypt = */ true, + ); + + const accountJson = JSON.parse(accountStr) as SerializedAccount; + const keyPair = { + privateKey: await crypto.subtle.importKey( + "jwk", + accountJson.privateKey, + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign"], + ), + publicKey: await crypto.subtle.importKey( + "jwk", + accountJson.publicKey, + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["verify"], + ), + }; + try { + return await client.login({ keyPair }); + } catch (e) { + if (e instanceof AccountDoesNotExistError) { + if (!isRetry) { + logger.warn( + e, + "Previously-used ACME account does not exist; retrying with a new account...", + ); + // Try clearing the current account and generating a new one. + // This probably happened because we're in development and restarted Pebble (the ACME server), which clears its state. + await this.cacheService.remove(cacheKey); + return await this.login(true); + } else { + throw e; + } + } + throw e; + } + } + + async generateCert(domainId: number) { + const domain = await this.domainRepo.getById(domainId); + const account = await this.login(); + const order = await account.createOrder({ domains: [domain.name] }); + const http01Challenges = order.authorizations.map((authorization) => + authorization.findChallenge("http-01"), + ); + if (http01Challenges.length !== 1) { + throw new Error( + "Unexpected response: got " + + order.authorizations.length + + " authorizations, expected 1", + ); + } + const challenge = http01Challenges[0]; + + await this.domainRepo.updateCertOrderDetails( + domainId, + challenge.token, + await challenge.keyAuthorization(), + order.url, + ); + + // Tell the CA that we're ready to run our HTTP challenge + await challenge.submit(); + + // The rest of the process continues in handleAcmeChallenge, then in finalizeCertOrder + } + + async handleAcmeChallenge(token: string) { + const domain = await this.domainRepo.getByToken(token); + if (!domain) { + throw new DomainNotFoundError(); + } + logger.info( + { domain: { id: domain.id, name: domain.name } }, + "Handling ACME challenge", + ); + + if (domain.status === "GENERATING") { + // We've already started waiting for the process to complete while handling another request; return early + return domain.keyAuthorization; + } + + if (domain.status === "PENDING") { + await this.domainRepo.setStatus(domain.id, "GENERATING"); + } else { + throw new ValidationError( + "Unexpected domain verification status: " + domain.status, + ); + } + + // Start waiting for the certificate to be generated + void (async () => { + try { + await this.finalizeCertOrder(domain.id); + } catch (e) { + logger.warn( + { err: e, domainId: domain.id }, + "Failed to generate certificate", + ); + await this.domainRepo.setStatus(domain.id, "ERROR"); + } + })(); + + return domain.keyAuthorization; + } + + private async finalizeCertOrder(domainId: number) { + const domain = await this.domainRepo.getById(domainId); + + const config = await this.appRepo.getDeploymentConfig(domain.appId); + if (config.appType !== "workload") { + throw new ValidationError( + "Cannot generate certificates for this kind of application.", + ); + } + + const account = await this.login(); + const order = await AcmeOrder.init({ account, url: domain.orderURL }); + + // Wait until the challenge completes + await order.pollStatus({ pollUntil: "ready" }); + + // Request a certificate + const keyPair = await order.finalize(); + + // Wait until the certificate is generated + await order.pollStatus({ pollUntil: "valid" }); + + const certificatePemContent = await order.getCertificate(); + + const { notBefore, notAfter } = CertUtils.decodeValidity( + certificatePemContent, + ); + + const exportedPrivateKey = await crypto.subtle.exportKey( + "pkcs8", + keyPair.privateKey, + ); + + const privateKeyPem = `-----BEGIN PRIVATE KEY-----\n${Buffer.from(exportedPrivateKey).toString("base64")}\n-----END PRIVATE KEY-----`; + + const app = await this.appRepo.getById(domain.appId); + + // Place the new certificate into a Secret in the app's namespace + const secret: K8sObject & V1Secret = { + apiVersion: "v1", + kind: "Secret", + type: "kubernetes.io/tls", + metadata: { + name: `anvilops-tls-${domain.id}`, + namespace: app.namespace, + }, + data: { + "tls.crt": Buffer.from(certificatePemContent).toString("base64"), + "tls.key": Buffer.from(privateKeyPem).toString("base64"), + }, + }; + + await this.domainRepo.markAsGenerated(domainId, notBefore, notAfter); + const domains = await this.domainRepo.listByAppId(domain.appId); + const ingress = this.ingressService.createIngressConfig({ + createIngress: config.createIngress, + customDomains: domains, + name: app.name, + namespace: app.namespace, + port: config.port, + serviceName: app.namespace, + subdomain: config.subdomain, + servicePort: 80, + }); + // (this new Ingress config will contain a reference to the new certificate since its status is now Generated) + + const namespace = createNamespaceConfig(app.namespace, app.projectId); + + await this.k8sService.createOrUpdateApp(app, namespace, [secret, ingress]); + } +} + +declare global { + interface CryptoKeyPair { + privateKey: webcrypto.CryptoKey; + publicKey: webcrypto.CryptoKey; + } +} diff --git a/backend/src/service/common/cache.ts b/backend/src/service/common/cache.ts index e3f4ae3b..7f823387 100644 --- a/backend/src/service/common/cache.ts +++ b/backend/src/service/common/cache.ts @@ -26,12 +26,13 @@ export class KVCacheService { key: string, ttl: number | Date, mapper: () => Promise, + encrypt: boolean = false, ): Promise { - const result = await this.get(key); + const result = await this.get(key, encrypt); if (typeof result !== "string") { const value = await mapper(); try { - this.set(key, value, ttl).catch((err) => { + this.set(key, value, ttl, encrypt).catch((err) => { logger.error(err, "Failed to update value in cache"); }); // (We aren't `await`ing this because it should happen in the background) @@ -44,7 +45,14 @@ export class KVCacheService { return result; } - async get(key: string): Promise { + async remove(key: string) { + await this.cacheRepo.remove(key); + } + + async get( + key: string, + encrypt: boolean = false, + ): Promise { const localResult = this.localCache.get(key); if (localResult) { return Promise.resolve(localResult); @@ -59,14 +67,21 @@ export class KVCacheService { } try { - return await this.cacheRepo.get(key); + return encrypt + ? await this.cacheRepo.getEncrypted(key) + : await this.cacheRepo.get(key); } catch (e) { logger.warn({ cacheKey: key, error: e }, "Failed to look up cache key"); return undefined; } } - async set(key: string, value: string, ttl: Date | number): Promise { + async set( + key: string, + value: string, + ttl: Date | number, + encrypt: boolean = false, + ): Promise { const expiresAt = ttl instanceof Date ? ttl : new Date(new Date().getTime() + ttl * 1000); @@ -79,6 +94,10 @@ export class KVCacheService { noUpdateTTL: true, }); - await this.cacheRepo.set(key, value, expiresAt); + if (encrypt) { + await this.cacheRepo.setEncrypted(key, value, expiresAt); + } else { + await this.cacheRepo.set(key, value, expiresAt); + } } } diff --git a/backend/src/service/common/cluster/resources.ts b/backend/src/service/common/cluster/resources.ts index 378fcaa5..a396eac9 100644 --- a/backend/src/service/common/cluster/resources.ts +++ b/backend/src/service/common/cluster/resources.ts @@ -12,6 +12,7 @@ import type { App, AppGroup, Deployment, + Domain, Organization, WorkloadConfig, } from "../../../db/models.ts"; @@ -112,6 +113,7 @@ export class ClusterResourcesService { appGroup, deployment, config, + customDomains, migrating = false, }: { org: Organization; @@ -119,6 +121,7 @@ export class ClusterResourcesService { appGroup: AppGroup; deployment: Deployment; config: WorkloadConfig; + customDomains: Domain[]; migrating?: boolean; }) { const namespace = createNamespaceConfig(app.namespace, app.projectId); @@ -167,14 +170,15 @@ export class ClusterResourcesService { mounts: config.mounts, requests: config.requests, limits: config.limits, + customDomains: customDomains, }; - const svc = this.serviceConfigService.createServiceConfig(params); + const services = this.serviceConfigService.createServiceConfig(params); const ingress = this.ingressConfigService.createIngressConfig(params); const statefulSet = await this.statefulSetConfigService.createStatefulSetConfig(params); - configs.push(statefulSet, svc); + configs.push(statefulSet, ...services); if (ingress !== null) { // ^ Can be null if APP_DOMAIN is not set, meaning no Ingress should be created for the app configs.push(ingress); diff --git a/backend/src/service/common/cluster/resources/ingress.ts b/backend/src/service/common/cluster/resources/ingress.ts index 8aa0413a..38840a71 100644 --- a/backend/src/service/common/cluster/resources/ingress.ts +++ b/backend/src/service/common/cluster/resources/ingress.ts @@ -1,4 +1,5 @@ import { ApiException, type V1Ingress } from "@kubernetes/client-node"; +import type { Domain } from "../../../../db/models.ts"; import type { KubernetesClientService } from "../kubernetes.ts"; import type { K8sObject } from "../resources.ts"; @@ -9,6 +10,7 @@ interface IngressInterface { port: number; servicePort?: number; subdomain: string; + customDomains: Domain[]; createIngress: boolean; } @@ -42,6 +44,8 @@ export class IngressConfigService { const appDomain = new URL(this.appDomain); const hostname = app.subdomain + "." + appDomain.hostname; + const domains = [{ id: -1, name: hostname }, ...app.customDomains]; + return { apiVersion: "networking.k8s.io/v1", kind: "Ingress", @@ -50,30 +54,50 @@ export class IngressConfigService { namespace: app.namespace, }, spec: { + tls: domains + .filter((it) => "status" in it && it.status === "GENERATED") + .map((d) => ({ + hosts: [d.name], + secretName: `anvilops-tls-${d.id}`, + })), ingressClassName: this.ingressClassName, - rules: [ - { - host: hostname, - http: { - paths: [ - { - pathType: "Prefix", - path: "/", - backend: { - service: { - name: app.serviceName, - port: { - number: 80, - }, + rules: domains.map((domain) => ({ + host: domain.name, + http: { + paths: [ + { + pathType: "Prefix", + path: "/", + backend: { + service: { + name: app.serviceName, + port: { + number: 80, }, }, }, - ], - }, + }, + ...(domain.id !== -1 // Custom domains need to be able to respond to ACME challenges for generating certificates + ? [ + { + pathType: "Prefix", + path: "/.well-known/acme-challenge/", + backend: { + service: { + // This is an ExternalName Service that points to the AnvilOps backend in the main namespace. + // We need this because Ingress rules must point to services within their namespace. + name: "anvilops-backend", + port: { number: 80 }, + }, + }, + }, + ] + : []), + ], }, - ], + })), }, - }; + } satisfies V1Ingress; } /** @@ -88,6 +112,7 @@ export class IngressConfigService { port: 80, serviceName: "anvilops-ingress-probe", subdomain: subdomain, + customDomains: [], servicePort: 80, }); diff --git a/backend/src/service/common/cluster/resources/service.ts b/backend/src/service/common/cluster/resources/service.ts index c935b5d9..0b677c31 100644 --- a/backend/src/service/common/cluster/resources/service.ts +++ b/backend/src/service/common/cluster/resources/service.ts @@ -10,27 +10,51 @@ interface ServiceParams { } export class ServiceConfigService { - createServiceConfig(app: ServiceParams): V1Service & K8sObject { - return { - apiVersion: "v1", - kind: "Service", - metadata: { - name: app.serviceName, - namespace: app.namespace, - }, - spec: { - type: "ClusterIP", - selector: { - app: app.name, + private internalBaseURL: string; + + constructor(internalBaseURL: string) { + this.internalBaseURL = internalBaseURL; + } + + createServiceConfig(app: ServiceParams): (V1Service & K8sObject)[] { + return [ + { + apiVersion: "v1", + kind: "Service", + metadata: { + name: app.serviceName, + namespace: app.namespace, }, - ports: [ - { - port: app.servicePort ?? 80, - targetPort: app.port, - protocol: "TCP", + spec: { + type: "ClusterIP", + selector: { + app: app.name, }, - ], + ports: [ + { + port: app.servicePort ?? 80, + targetPort: app.port, + protocol: "TCP", + }, + ], + }, + }, + { + apiVersion: "v1", + kind: "Service", + metadata: { + name: "anvilops-backend", + namespace: app.namespace, + }, + spec: { + // This Service points to the AnvilOps backend. We need this for certificate generation: + // solving ACME challenges requires making a certain URL available on the domain that needs to be verified, + // and we want to redirect that URL to the AnvilOps backend so that it can solve the challenge and continue generating the certificate. + // See src/service/cluster/resources/ingress.ts and src/service/certGeneration.ts. + type: "ExternalName", + externalName: new URL(this.internalBaseURL).hostname, + }, }, - }; + ]; } } diff --git a/backend/src/service/common/deployment.ts b/backend/src/service/common/deployment.ts index fbb8723b..51746fcf 100644 --- a/backend/src/service/common/deployment.ts +++ b/backend/src/service/common/deployment.ts @@ -13,6 +13,8 @@ import type { import type { AppRepo } from "../../db/repo/app.ts"; import type { AppGroupRepo } from "../../db/repo/appGroup.ts"; import type { DeploymentRepo } from "../../db/repo/deployment.ts"; +import type { DomainRepo } from "../../db/repo/domain.ts"; +import type { OrganizationRepo } from "../../db/repo/organization.ts"; import { logger } from "../../logger.ts"; import { DeploymentError } from "../errors/index.ts"; import { log } from "../githubWebhook.ts"; @@ -34,9 +36,11 @@ type GitOptions = }; export class DeploymentService { + private orgRepo: OrganizationRepo; private appRepo: AppRepo; private appGroupRepo: AppGroupRepo; private deploymentRepo: DeploymentRepo; + private domainRepo: DomainRepo; private helmService: HelmService; private gitProviderFactoryService: GitProviderFactoryService; private builderService: BuilderService; @@ -45,9 +49,11 @@ export class DeploymentService { private baseURL: string; constructor( + orgRepo: OrganizationRepo, appRepo: AppRepo, appGroupRepo: AppGroupRepo, deploymentRepo: DeploymentRepo, + domainRepo: DomainRepo, helmService: HelmService, gitProviderFactoryService: GitProviderFactoryService, builderService: BuilderService, @@ -55,9 +61,11 @@ export class DeploymentService { kubernetesClientService: KubernetesClientService, baseURL: string, ) { + this.orgRepo = orgRepo; this.appRepo = appRepo; this.appGroupRepo = appGroupRepo; this.deploymentRepo = deploymentRepo; + this.domainRepo = domainRepo; this.helmService = helmService; this.gitProviderFactoryService = gitProviderFactoryService; this.builderService = builderService; @@ -71,27 +79,29 @@ export class DeploymentService { * @throws DeploymentError */ async create({ - org, - app, + appId, commitMessage, workflowRunId, config: configIn, git, }: { - org: Organization; - app: App; + appId: number; commitMessage: string; workflowRunId?: number; config: WorkloadConfigCreate | GitConfigCreate | HelmConfigCreate; git?: GitOptions; }) { - const deployment = await this.deploymentRepo.create({ - appId: app.id, - commitMessage, - workflowRunId, - config: configIn, - ...(git?.checkRun?.pending && { status: "PENDING" }), - }); + const app = await this.appRepo.getById(appId); + const [org, deployment] = await Promise.all([ + this.orgRepo.getById(app.orgId), + this.deploymentRepo.create({ + appId: app.id, + commitMessage, + workflowRunId, + config: configIn, + ...(git?.checkRun?.pending && { status: "PENDING" }), + }), + ]); const config = await this.deploymentRepo.getConfig(deployment.id); if (!app.configId) { @@ -367,7 +377,7 @@ export class DeploymentService { } /** - * Generates Kuernetes configs from the AnvilOps deployment object, applies them to the cluster, and updates the deployment's status to Complete. + * Generates Kubernetes configs from the AnvilOps deployment object, applies them to the cluster, and updates the deployment's status to Complete. */ async finishDeployment( org: Organization, @@ -377,12 +387,14 @@ export class DeploymentService { config: WorkloadConfig, ) { try { + const customDomains = await this.domainRepo.listByAppId(app.id); const { namespace, configs, postCreate } = await this.clusterResourcesService.createAppConfigsFromDeployment({ org, app, appGroup, deployment, + customDomains, config, }); await this.kubernetesClientService.createOrUpdateApp( diff --git a/backend/src/service/createApp.ts b/backend/src/service/createApp.ts index a3a669da..d9d00e19 100644 --- a/backend/src/service/createApp.ts +++ b/backend/src/service/createApp.ts @@ -135,8 +135,7 @@ export class CreateAppService { try { await this.deploymentService.create({ - org: organization, - app, + appId: app.id, commitMessage, config: deploymentConfig, }); diff --git a/backend/src/service/createAppGroup.ts b/backend/src/service/createAppGroup.ts index ee507554..22c9637b 100644 --- a/backend/src/service/createAppGroup.ts +++ b/backend/src/service/createAppGroup.ts @@ -123,8 +123,7 @@ export class CreateAppGroupService { try { await this.deploymentService.create({ - org: organization, - app, + appId: app.id, commitMessage, config, }); diff --git a/backend/src/service/customDomain.ts b/backend/src/service/customDomain.ts new file mode 100644 index 00000000..2e251739 --- /dev/null +++ b/backend/src/service/customDomain.ts @@ -0,0 +1,127 @@ +import dns from "node:dns/promises"; +import { parse } from "tldts"; +import { ConflictError } from "../db/errors/index.ts"; +import type { DomainRepo } from "../db/repo/domain.ts"; +import { PrivateSuffixError, ValidationError } from "./errors/index.ts"; + +interface RequiredDnsRecord { + name: string; + type: "CNAME" | "A" | "TXT"; + content: string; +} + +// Prefer Cloudflare's DNS service - it's likely to have more up-to-date results than the system's DNS service +// https://one.one.one.one/dns/ +dns.setServers([ + "1.1.1.1", + "1.0.0.1", + "2606:4700:4700::1111", + "2606:4700:4700::1001", + ...(dns.getServers() ?? []), +]); + +export class CustomDomainService { + private domainRepo: DomainRepo; + private cnameDomain: string; + + constructor(domainRepo: DomainRepo, cnameDomain: string) { + this.domainRepo = domainRepo; + this.cnameDomain = cnameDomain; + } + + validateDomainName(domain: string) { + const info = parse(domain, { allowPrivateDomains: true }); + if (info.isPrivate) { + throw new PrivateSuffixError(); + } + if (!info.domain) { + throw new ValidationError("Invalid domain name"); + } + return info; + } + + /** + * Returns the DNS records that the user must set to add a custom domain with the provided name. + */ + async getRequiredDNSRecords(domain: string, verificationToken: string) { + const info = this.validateDomainName(domain); + + const records: RequiredDnsRecord[] = []; + + if (info.subdomain) { + // The domain should have a CNAME that points to the app's default subdomain + records.push({ + name: info.subdomain, + type: "CNAME", + content: this.cnameDomain, + }); + } else { + // CNAMEs aren't allowed at the apex domain, so ask for an A record instead + const ips = await dns.resolve4(this.cnameDomain); // We know this domain points to the ingress controller, so use its public IP address + if (ips.length === 0) { + throw new Error("Failed to resolve address for CNAME domain"); + } + records.push( + ...ips.map((ip) => ({ name: "@", type: "A", content: ip }) as const), + ); + } + + records.push({ + name: info.subdomain || "@", + type: "TXT", + content: `_anvilops_domain_verification=${verificationToken}`, + }); + + return records; + } + + async create(appId: number, domain: string) { + const existingDomains = await this.domainRepo.getByName(domain); + + if (existingDomains.length > 0) { + throw new ConflictError(); + } + + return await this.domainRepo.create(appId, domain); + } + + async verifyDNSRecords(domainName: string, verificationToken: string) { + const expectedRecords = await this.getRequiredDNSRecords( + domainName, + verificationToken, + ); + + await Promise.all( + expectedRecords.map(async (expected) => { + switch (expected.type) { + case "A": { + const ips = await dns.resolve4(domainName); + if (!ips.includes(expected.content)) { + throw new ValidationError("Could not find matching A record"); + } + break; + } + case "CNAME": { + const names = await dns.resolveCname(domainName); + if (!names.includes(expected.content)) { + throw new ValidationError("Could not find matching CNAME record"); + } + break; + } + case "TXT": { + const records = await dns.resolveTxt(domainName); + if (!records.some((rec) => rec.includes(expected.content))) { + throw new ValidationError("Could not find matching TXT record"); + } + break; + } + default: { + expected.type satisfies never; + } + } + }), + ); + + return true; + } +} diff --git a/backend/src/service/deleteApp.ts b/backend/src/service/deleteApp.ts index 1e22c331..91e5f275 100644 --- a/backend/src/service/deleteApp.ts +++ b/backend/src/service/deleteApp.ts @@ -2,6 +2,7 @@ import { SpanStatusCode, trace } from "@opentelemetry/api"; import type { AppRepo } from "../db/repo/app.ts"; import type { AppGroupRepo } from "../db/repo/appGroup.ts"; import type { DeploymentRepo } from "../db/repo/deployment.ts"; +import type { DomainRepo } from "../db/repo/domain.ts"; import type { OrganizationRepo } from "../db/repo/organization.ts"; import { logger } from "../logger.ts"; import type { KubernetesClientService } from "./common/cluster/kubernetes.ts"; @@ -17,12 +18,14 @@ export class DeleteAppService { private registryService: RegistryService; private clusterResourcesService: ClusterResourcesService; private kubernetesService: KubernetesClientService; + private domainRepo: DomainRepo; constructor( orgRepo: OrganizationRepo, appRepo: AppRepo, appGroupRepo: AppGroupRepo, deploymentRepo: DeploymentRepo, + domainRepo: DomainRepo, registryService: RegistryService, clusterResourcesService: ClusterResourcesService, kubernetesService: KubernetesClientService, @@ -31,6 +34,7 @@ export class DeleteAppService { this.appRepo = appRepo; this.appGroupRepo = appGroupRepo; this.deploymentRepo = deploymentRepo; + this.domainRepo = domainRepo; this.registryService = registryService; this.clusterResourcesService = clusterResourcesService; this.kubernetesService = kubernetesService; @@ -84,9 +88,10 @@ export class DeleteAppService { config.collectLogs = false; // <-- Disable log shipping const app = await this.appRepo.getById(lastDeployment.appId); - const [org, appGroup] = await Promise.all([ + const [org, appGroup, domains] = await Promise.all([ this.orgRepo.getById(app.orgId), this.appGroupRepo.getById(app.appGroupId), + this.domainRepo.listByAppId(app.id), ]); const { namespace, configs, postCreate } = @@ -96,6 +101,7 @@ export class DeleteAppService { appGroup, deployment: lastDeployment, config, + customDomains: domains, migrating: true, // Deploy without any anvilops-related labels }); diff --git a/backend/src/service/errors/index.ts b/backend/src/service/errors/index.ts index 8a65dcc5..141c4e37 100644 --- a/backend/src/service/errors/index.ts +++ b/backend/src/service/errors/index.ts @@ -24,6 +24,8 @@ export class UserAlreadyInvitedError extends Error {} export class DeploymentNotFoundError extends Error {} +export class DomainNotFoundError extends Error {} + export class ValidationError extends Error {} export class DeploymentError extends Error { @@ -91,6 +93,13 @@ export class InvalidIDPError extends Error {} */ export class RancherIDNotFoundError extends Error {} +/** + * Thrown when trying to add a domain that has a private suffix. + * Private suffixes are domains like *.vercel.app or *.firebaseapp.com that the end user doesn't control. + * Users shouldn't be able to use these for custom domains. + */ +export class PrivateSuffixError extends Error {} + /** * Thrown from the listCharts service when no charts could be retrieved. */ diff --git a/backend/src/service/githubWebhook.ts b/backend/src/service/githubWebhook.ts index d4b3618d..c0d0d2ca 100644 --- a/backend/src/service/githubWebhook.ts +++ b/backend/src/service/githubWebhook.ts @@ -268,7 +268,6 @@ export class GitHubWebhookService { await Promise.all( apps.map(async (app) => { - const org = await this.orgRepo.getById(app.orgId); const oldConfig = ( await this.appRepo.getDeploymentConfig(app.id) ).asGitConfig(); @@ -278,8 +277,7 @@ export class GitHubWebhookService { payload.head_commit.id, ); await this.deploymentService.create({ - org, - app, + appId: app.id, commitMessage: payload.head_commit.message, config, git: { @@ -338,7 +336,6 @@ export class GitHubWebhookService { if (payload.action === "requested") { await Promise.all( apps.map(async (app) => { - const org = await this.orgRepo.getById(app.orgId); const oldConfig = ( await this.appRepo.getDeploymentConfig(app.id) ).asGitConfig(); @@ -348,8 +345,7 @@ export class GitHubWebhookService { payload.workflow_run.head_commit.id, ); await this.deploymentService.create({ - org, - app, + appId: app.id, commitMessage: payload.workflow_run.head_commit.message, workflowRunId: payload.workflow_run.id, config, diff --git a/backend/src/service/index.ts b/backend/src/service/index.ts index 3cc23644..b36c5d6e 100644 --- a/backend/src/service/index.ts +++ b/backend/src/service/index.ts @@ -1,7 +1,9 @@ import { PgDatabase, type Database } from "../db/index.ts"; import { env, parseCsv } from "../lib/env.ts"; import { AcceptInvitationService } from "./acceptInvitation.ts"; +import { AddDomainService } from "./addDomain.ts"; import { AuthService } from "./auth.ts"; +import { CertGenerationService } from "./certGeneration.ts"; import { ClaimOrgService } from "./claimOrg.ts"; import { AppService } from "./common/app.ts"; import { BuilderService } from "./common/builder.ts"; @@ -23,6 +25,7 @@ import { RegistryService } from "./common/registry.ts"; import { CreateAppService } from "./createApp.ts"; import { CreateAppGroupService } from "./createAppGroup.ts"; import { CreateOrgService } from "./createOrg.ts"; +import { CustomDomainService } from "./customDomain.ts"; import { DeleteAppService } from "./deleteApp.ts"; import { DeleteAppPodService } from "./deleteAppPod.ts"; import { DeleteOrgByIDService } from "./deleteOrgByID.ts"; @@ -47,15 +50,19 @@ import { IsNamespaceAvailableService } from "./isNamespaceAvailable.ts"; import { IsSubdomainAvailableService } from "./isSubdomainAvailable.ts"; import { ListChartsService } from "./listCharts.ts"; import { ListDeploymentsService } from "./listDeployments.ts"; +import { ListDomainsService } from "./listDomains.ts"; import { ListOrgGroupsService } from "./listOrgGroups.ts"; import { ListOrgReposService } from "./listOrgRepos.ts"; import { ListRepoBranchesService } from "./listRepoBranches.ts"; import { ListRepoWorkflowsService } from "./listRepoWorkflows.ts"; +import { RemoveDomainService } from "./removeDomain.ts"; import { RemoveUserFromOrgService } from "./removeUserFromOrg.ts"; +import { RetryCertGenService } from "./retryCertGen.ts"; import { RevokeInvitationService } from "./revokeInvitation.ts"; import { SetAppCDService } from "./setAppCD.ts"; import { UpdateAppService } from "./updateApp.ts"; import { UpdateDeploymentService } from "./updateDeployment.ts"; +import { VerifyDomainService } from "./verifyDomain.ts"; export const db: Database = new PgDatabase( env.DATABASE_URL ?? @@ -136,7 +143,9 @@ export const authService = new AuthService( env.LOGIN_CLAIM, ); -export const serviceConfigService = new ServiceConfigService(); +export const serviceConfigService = new ServiceConfigService( + env.CLUSTER_INTERNAL_BASE_URL, +); export const logCollectionService = new LogCollectionService( registryService, @@ -221,9 +230,11 @@ export const helmService = new HelmService( ); export const deploymentService = new DeploymentService( + db.org, db.app, db.appGroup, db.deployment, + db.domain, helmService, gitProviderFactoryService, builderService, @@ -265,6 +276,7 @@ export const deleteAppService = new DeleteAppService( db.app, db.appGroup, db.deployment, + db.domain, registryService, clusterResourcesService, kubernetesClientService, @@ -469,3 +481,48 @@ export const updateDeploymentService = new UpdateDeploymentService( gitProviderFactoryService, deploymentService, ); + +export const certGenerationService = new CertGenerationService( + cacheService, + db.app, + db.domain, + ingressConfigService, + kubernetesClientService, + env.ACME_SERVER_ADDRESS, +); + +export const customDomainService = new CustomDomainService( + db.domain, + env.CNAME_DOMAIN, +); + +export const listDomainsService = new ListDomainsService( + db.domain, + customDomainService, +); + +export const addDomainService = new AddDomainService( + db.domain, + customDomainService, +); + +export const verifyDomainService = new VerifyDomainService( + db.app, + db.domain, + customDomainService, + certGenerationService, + ingressConfigService, + kubernetesClientService, +); + +export const retryCertGenService = new RetryCertGenService( + certGenerationService, + db.domain, +); + +export const removeDomainService = new RemoveDomainService( + db.domain, + db.app, + ingressConfigService, + kubernetesClientService, +); diff --git a/backend/src/service/listDomains.ts b/backend/src/service/listDomains.ts new file mode 100644 index 00000000..63616d89 --- /dev/null +++ b/backend/src/service/listDomains.ts @@ -0,0 +1,44 @@ +import type { DomainRepo } from "../db/repo/domain.ts"; +import type { components } from "../generated/openapi.ts"; +import type { CustomDomainService } from "./customDomain.ts"; + +export class ListDomainsService { + private domainRepo: DomainRepo; + private customDomainService: CustomDomainService; + + constructor( + domainRepo: DomainRepo, + customDomainService: CustomDomainService, + ) { + this.domainRepo = domainRepo; + this.customDomainService = customDomainService; + } + + async listDomains( + appId: number, + userId: number, + ): Promise { + const domains = await this.domainRepo.listByAppId(appId, { + requireUser: { id: userId }, + }); + + return await Promise.all( + domains.map(async (d) => { + return { + domain: d.name, + id: d.id, + appId: d.appId, + status: d.status, + updatedAt: d.updatedAt.toISOString(), + dnsRecords: + d.status === "UNVERIFIED" + ? await this.customDomainService.getRequiredDNSRecords( + d.name, + d.verificationToken, + ) + : null, + }; + }), + ); + } +} diff --git a/backend/src/service/removeDomain.ts b/backend/src/service/removeDomain.ts new file mode 100644 index 00000000..4d0d69c9 --- /dev/null +++ b/backend/src/service/removeDomain.ts @@ -0,0 +1,81 @@ +import { ApiException } from "@kubernetes/client-node"; +import type { AppRepo } from "../db/repo/app.ts"; +import type { DomainRepo } from "../db/repo/domain.ts"; +import type { KubernetesClientService } from "./common/cluster/kubernetes.ts"; +import { createNamespaceConfig } from "./common/cluster/resources.ts"; +import type { IngressConfigService } from "./common/cluster/resources/ingress.ts"; +import { DomainNotFoundError, ValidationError } from "./errors/index.ts"; + +export class RemoveDomainService { + private domainRepo: DomainRepo; + + private appRepo: AppRepo; + private ingressService: IngressConfigService; + private k8sService: KubernetesClientService; + + constructor( + domainRepo: DomainRepo, + appRepo: AppRepo, + ingressService: IngressConfigService, + k8sService: KubernetesClientService, + ) { + this.domainRepo = domainRepo; + this.appRepo = appRepo; + this.ingressService = ingressService; + this.k8sService = k8sService; + } + + async removeDomain(domainId: number, userId: number) { + const domain = await this.domainRepo.getById(domainId, { + requireUser: { id: userId }, + }); + if (!domain) { + throw new DomainNotFoundError(); + } + + const app = await this.appRepo.getById(domain.appId); + const config = await this.appRepo.getDeploymentConfig(domain.appId); + if (config.appType !== "workload") { + throw new ValidationError( + "Custom domains are not supported on this app type.", + ); + } + + // Remove the Ingress rule that directs traffic from the custom domain to the app + const domains = await this.domainRepo.listByAppId(app.id); + const namespace = createNamespaceConfig(app.namespace, app.projectId); + const ingress = this.ingressService.createIngressConfig({ + createIngress: config.createIngress, + customDomains: domains, + name: app.name, + namespace: app.namespace, + port: config.port, + serviceName: app.namespace, + subdomain: config.subdomain, + servicePort: 80, + }); + await this.k8sService.createOrUpdateApp(app, namespace, [ingress]); + + // Remove the Secret that stores the TLS certificate + try { + const { CoreV1Api: api } = await this.k8sService.getClientsForRequest( + userId, + app.projectId, + ["CoreV1Api"], + ); + await api.deleteNamespacedSecret({ + namespace: app.namespace, + name: `anvilops-tls-${domain.id}`, + }); + } catch (e) { + if (e instanceof ApiException && e.code === 404) { + // The Secret doesn't exist, which is fine since we're deleting + } else { + throw e; + } + } + + // Remove the DB record associated with the domain + await this.domainRepo.delete(domainId, { requireUser: { id: userId } }); + } +} diff --git a/backend/src/service/retryCertGen.ts b/backend/src/service/retryCertGen.ts new file mode 100644 index 00000000..43589b17 --- /dev/null +++ b/backend/src/service/retryCertGen.ts @@ -0,0 +1,44 @@ +import type { DomainRepo } from "../db/repo/domain.ts"; +import { logger } from "../logger.ts"; +import type { CertGenerationService } from "./certGeneration.ts"; +import { ValidationError } from "./errors/index.ts"; + +/** + * The amount of time that the user needs to wait to retry generating a certificate if it's stuck in the Pending or Generating phase. + * If the domain is in the Error state, it can be retried instantly. + */ +const DOMAIN_RETRY_COOLDOWN = 1000 * 60 * 5; // 5 minutes + +export class RetryCertGenService { + private certGenService: CertGenerationService; + private domainRepo: DomainRepo; + constructor(certGenService: CertGenerationService, domainRepo: DomainRepo) { + this.certGenService = certGenService; + this.domainRepo = domainRepo; + } + + async retryCertGen(userId: number, domainId: number) { + const domain = await this.domainRepo.getById(domainId, { + requireUser: { id: userId }, + }); + + const isStuckGenerating = + (domain.status === "PENDING" || domain.status === "GENERATING") && + new Date().getTime() - domain.updatedAt.getTime() >= + DOMAIN_RETRY_COOLDOWN; + + if (domain.status !== "ERROR" && !isStuckGenerating) { + throw new ValidationError( + "Can't retry unless a domain is errored or pending for at least 5 minutes.", + ); + } + + await this.domainRepo.setStatus(domain.id, "PENDING"); + try { + await this.certGenService.generateCert(domain.id); + } catch (e) { + logger.error(e, "Failed to request certificate"); + await this.domainRepo.setStatus(domain.id, "ERROR"); + } + } +} diff --git a/backend/src/service/updateApp.ts b/backend/src/service/updateApp.ts index e189a5c1..9becdfbc 100644 --- a/backend/src/service/updateApp.ts +++ b/backend/src/service/updateApp.ts @@ -183,8 +183,7 @@ export class UpdateAppService { try { await this.deploymentService.create({ - org: organization, - app, + appId: app.id, commitMessage, config: updatedConfig, git: { diff --git a/backend/src/service/verifyDomain.ts b/backend/src/service/verifyDomain.ts new file mode 100644 index 00000000..e9eaf39c --- /dev/null +++ b/backend/src/service/verifyDomain.ts @@ -0,0 +1,82 @@ +import type { AppRepo } from "../db/repo/app.ts"; +import type { DomainRepo } from "../db/repo/domain.ts"; +import { logger } from "../logger.ts"; +import type { CertGenerationService } from "./certGeneration.ts"; +import type { KubernetesClientService } from "./common/cluster/kubernetes.ts"; +import { createNamespaceConfig } from "./common/cluster/resources.ts"; +import type { IngressConfigService } from "./common/cluster/resources/ingress.ts"; +import type { CustomDomainService } from "./customDomain.ts"; +import { ValidationError } from "./errors/index.ts"; + +export class VerifyDomainService { + private domainRepo: DomainRepo; + private customDomainService: CustomDomainService; + private certGenService: CertGenerationService; + + // Used to recreate the Ingress after the domain is verified + private appRepo: AppRepo; + private ingressService: IngressConfigService; + private k8sService: KubernetesClientService; + + constructor( + appRepo: AppRepo, + domainRepo: DomainRepo, + customDomainService: CustomDomainService, + certGenService: CertGenerationService, + ingressService: IngressConfigService, + k8sService: KubernetesClientService, + ) { + this.appRepo = appRepo; + this.domainRepo = domainRepo; + this.customDomainService = customDomainService; + this.certGenService = certGenService; + this.ingressService = ingressService; + this.k8sService = k8sService; + } + + async verifyDomain(domainId: number, userId: number) { + const domain = await this.domainRepo.getById(domainId, { + requireUser: { id: userId }, + }); + + const app = await this.appRepo.getById(domain.appId); + const config = await this.appRepo.getDeploymentConfig(app.id); + if (config.appType !== "workload") { + throw new ValidationError( + "Custom domains are not supported with this type of application.", + ); + } + + await this.customDomainService.verifyDNSRecords( + domain.name, + domain.verificationToken, + ); + + // If no error was thrown, the domain's DNS records were valid. + + // Create an Ingress rule that works for the new hostname + const domains = await this.domainRepo.listByAppId(app.id); + const namespace = createNamespaceConfig(app.namespace, app.projectId); + const ingress = this.ingressService.createIngressConfig({ + createIngress: config.createIngress, + customDomains: domains, + name: app.name, + namespace: app.namespace, + port: config.port, + serviceName: app.namespace, + subdomain: config.subdomain, + servicePort: 80, + }); + await this.k8sService.createOrUpdateApp(app, namespace, [ingress]); + + // Begin generating a certificate for the domain + await this.domainRepo.setStatus(domainId, "PENDING"); + + try { + await this.certGenService.generateCert(domain.id); + } catch (e) { + logger.error(e, "Failed to submit certificate order"); + await this.domainRepo.setStatus(domainId, "ERROR"); + } + } +} diff --git a/backend/src/types.ts b/backend/src/types.ts index 71081ed0..1344fc82 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -55,6 +55,7 @@ export type ResponseMap = { headers: { [name: string]: unknown }; content?: { "application/json"?: unknown; + "text/plain"?: unknown; "text/event-stream"?: unknown; "application/octet-stream"?: unknown; }; diff --git a/backend/test/db/repo/cache.spec.ts b/backend/test/db/repo/cache.spec.ts new file mode 100644 index 00000000..1944da4b --- /dev/null +++ b/backend/test/db/repo/cache.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; +import { createDB } from "../../util/db.ts"; + +test("encrypted cached values", async () => { + const db = await createDB(); + + await db.cache.setEncrypted( + "key", + "value", + new Date(new Date().getTime() + 86_400_000), + ); + + const value = await db.cache.getEncrypted("key"); + + expect(value).toEqual("value"); +}); diff --git a/backend/test/service/customDomain.spec.ts b/backend/test/service/customDomain.spec.ts new file mode 100644 index 00000000..5eb17a5f --- /dev/null +++ b/backend/test/service/customDomain.spec.ts @@ -0,0 +1,237 @@ +import { resolve4, resolveCname, resolveTxt } from "node:dns/promises"; +import { expect, test, vi } from "vitest"; +import { CustomDomainService } from "../../src/service/customDomain.ts"; + +vi.mock("node:dns/promises"); + +test("required DNS records", async () => { + const svc = new CustomDomainService(null, "cname-domain.test.local"); + vi.mocked(resolve4).mockResolvedValue(["1.1.1.1"]); + + // Top-level domains should get an A record and a TXT record + const topLevel = await svc.getRequiredDNSRecords( + "top-level.local", + "verification123", + ); + expect(topLevel).toMatchInlineSnapshot(` + [ + { + "content": "1.1.1.1", + "name": "@", + "type": "A", + }, + { + "content": "_anvilops_domain_verification=verification123", + "name": "@", + "type": "TXT", + }, + ] + `); + + // Subdomains should get a CNAME and a TXT + const subdomain = await svc.getRequiredDNSRecords( + "sub.top-level.local", + "verification123", + ); + expect(subdomain).toMatchInlineSnapshot(` + [ + { + "content": "cname-domain.test.local", + "name": "sub", + "type": "CNAME", + }, + { + "content": "_anvilops_domain_verification=verification123", + "name": "sub", + "type": "TXT", + }, + ] + `); +}); + +test("verify top-level domain", async () => { + const cnameDomain = "cname-domain.test.local"; + const svc = new CustomDomainService(null, cnameDomain); + + vi.clearAllMocks(); + vi.mocked(resolve4).mockResolvedValue(["1.1.1.1"]); + vi.mocked(resolveTxt).mockResolvedValue([ + ["_anvilops_domain_verification=verification123"], + ]); + + const isValid = await svc.verifyDNSRecords( + "top-level.local", + "verification123", + ); + expect(isValid).toBe(true); + + // Invalid verification token + await expect( + svc.verifyDNSRecords("top-level.local", "verification456"), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Could not find matching TXT record]`, + ); + + // Missing TXT record + vi.clearAllMocks(); + vi.mocked(resolve4).mockResolvedValue(["1.1.1.1"]); + vi.mocked(resolveTxt).mockResolvedValue([]); + + await expect( + svc.verifyDNSRecords("top-level.local", "verification123"), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Could not find matching TXT record]`, + ); + + // Missing A record + vi.clearAllMocks(); + vi.mocked(resolve4).mockImplementation((host) => + Promise.resolve(host === cnameDomain ? ["1.1.1.1"] : []), + ); + vi.mocked(resolveTxt).mockResolvedValue([ + ["_anvilops_domain_verification=verification123"], + ]); + await expect( + svc.verifyDNSRecords("top-level.local", "verification123"), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Could not find matching A record]`, + ); + + // Missing both records + vi.clearAllMocks(); + vi.mocked(resolve4).mockImplementation((host) => + Promise.resolve(host === cnameDomain ? ["1.1.1.1"] : []), + ); + vi.mocked(resolveTxt).mockResolvedValue([]); + await expect( + svc.verifyDNSRecords("top-level.local", "verification123"), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Could not find matching A record]`, + ); + + // Invalid A record + vi.clearAllMocks(); + vi.mocked(resolve4).mockImplementation((host) => + Promise.resolve(host === cnameDomain ? ["1.1.1.1"] : ["2.2.2.2"]), + ); + vi.mocked(resolveTxt).mockResolvedValue([ + ["_anvilops_domain_verification=verification123"], + ]); + await expect( + svc.verifyDNSRecords("top-level.local", "verification123"), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Could not find matching A record]`, + ); + + // CNAMEs aren't acceptable in place of A records for top-level domains + vi.clearAllMocks(); + vi.mocked(resolve4).mockImplementation((host) => + Promise.resolve(host === cnameDomain ? ["1.1.1.1"] : []), + ); + vi.mocked(resolveCname).mockResolvedValue(["cname-domain.test.local"]); + vi.mocked(resolveTxt).mockResolvedValue([ + ["_anvilops_domain_verification=verification123"], + ]); + await expect( + svc.verifyDNSRecords("top-level.local", "verification123"), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Could not find matching A record]`, + ); + + // TXT record with multiple entries + vi.clearAllMocks(); + vi.mocked(resolve4).mockResolvedValue(["1.1.1.1"]); + vi.mocked(resolveTxt).mockResolvedValue([ + [ + "extra-entry-1", + "_anvilops_domain_verification=verification123", + "extra-entry-2", + ], + ]); + expect(await svc.verifyDNSRecords("top-level.local", "verification123")).toBe( + true, + ); +}); + +test("verify subdomain", async () => { + const svc = new CustomDomainService(null, "cname-domain.test.local"); + + vi.clearAllMocks(); + vi.mocked(resolveCname).mockResolvedValue(["cname-domain.test.local"]); + vi.mocked(resolveTxt).mockResolvedValue([ + ["_anvilops_domain_verification=verification123"], + ]); + + const isValid = await svc.verifyDNSRecords( + "sub.top-level.local", + "verification123", + ); + expect(isValid).toBe(true); + + // Invalid verification token + await expect( + svc.verifyDNSRecords("sub.top-level.local", "verification456"), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Could not find matching TXT record]`, + ); + + // Missing TXT record + vi.clearAllMocks(); + vi.mocked(resolveCname).mockResolvedValue(["cname-domain.test.local"]); + vi.mocked(resolveTxt).mockResolvedValue([[]]); + + await expect( + svc.verifyDNSRecords("sub.top-level.local", "verification123"), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Could not find matching TXT record]`, + ); + + // Missing CNAME record + vi.clearAllMocks(); + vi.mocked(resolveCname).mockResolvedValue([]); + vi.mocked(resolveTxt).mockResolvedValue([ + ["_anvilops_domain_verification=verification123"], + ]); + await expect( + svc.verifyDNSRecords("sub.top-level.local", "verification123"), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Could not find matching CNAME record]`, + ); + + // Missing both records + vi.clearAllMocks(); + vi.mocked(resolveCname).mockResolvedValue([]); + vi.mocked(resolveTxt).mockResolvedValue([]); + await expect( + svc.verifyDNSRecords("sub.top-level.local", "verification123"), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Could not find matching CNAME record]`, + ); + + // Invalid CNAME record + vi.clearAllMocks(); + vi.mocked(resolveCname).mockResolvedValue([ + "incorrect-cname-domain.test.local", + ]); + vi.mocked(resolveTxt).mockResolvedValue([ + ["_anvilops_domain_verification=verification123"], + ]); + await expect( + svc.verifyDNSRecords("sub.top-level.local", "verification123"), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Could not find matching CNAME record]`, + ); + + // A records aren't acceptable in place of CNAME records for subdomains + vi.clearAllMocks(); + vi.mocked(resolve4).mockResolvedValue(["1.1.1.1"]); + vi.mocked(resolveCname).mockResolvedValue([]); + vi.mocked(resolveTxt).mockResolvedValue([ + ["_anvilops_domain_verification=verification123"], + ]); + await expect( + svc.verifyDNSRecords("sub.top-level.local", "verification123"), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Could not find matching CNAME record]`, + ); +}); diff --git a/charts/anvilops/README.md b/charts/anvilops/README.md index f943c22c..fa8d44fc 100644 --- a/charts/anvilops/README.md +++ b/charts/anvilops/README.md @@ -20,12 +20,14 @@ If an option has a ⭐ beside it, you will likely have to change it to fit your | | `anvilops.imagePullPolicy` | | `IfNotPresent` | | ⭐ | `anvilops.env.baseURL` | The base URL that AnvilOps should be publicly accessible at, including the protocol. Used to determine the redirect URL for CILogon. | http://anvilops.minikube.local | | ⭐ | `anvilops.env.appDomain` | The domain name that AnvilOps apps are deployed on. Users will be shown subdomains of this domain as their custom subdomain. | http://minikube.local | +| ⭐ | `anvilops.env.cnameDomain` | The domain name that custom domains are pointed to with CNAME DNS records. Populating this field with a domain name will create an Ingress at that hostname. | | +| ⭐ | `anvilops.env.acmeServerAddress` | The directory URL of an ACME server used to generate certificates for custom domains. **In production, switch this to a production endpoint** (e.g. the Let's Encrypt production server) that generates real certificates trusted by major browsers. | https://acme-staging-v02.api.letsencrypt.org/directory (Let's Encrypt staging) | | ⭐ | `anvilops.env.harborProjectName` | The name of the Harbor project to put users' AnvilOps apps in. | anvilops | | ⭐ | `anvilops.env.allowedIdps` | An optional comma-separated list of [CILogon EntityIDs](https://cilogon.org/idplist/) to allow login from. | https://access-ci.org/idp | -| ⭐ | `anvilops.cluster.name` | The name of the cluster, which will be displayed on the landing page. | -| ⭐ | `anvilops.cluster.faq.question` | The title of an FAQ item which will be displayed on the landing page, and can be used to describe the cluster. | -| ⭐ | `anvilops.cluster.faq.answer` | The answer of an FAQ item which will be displayed on the landing page, and can be used to describe the cluster. | -| ⭐ | `anvilops.cluster.faq.link` | A link to more information about the cluster. | +| ⭐ | `anvilops.cluster.name` | The name of the cluster, which will be displayed on the landing page. | | +| ⭐ | `anvilops.cluster.faq.question` | The title of an FAQ item which will be displayed on the landing page, and can be used to describe the cluster. | | +| ⭐ | `anvilops.cluster.faq.answer` | The answer of an FAQ item which will be displayed on the landing page, and can be used to describe the cluster. | | +| ⭐ | `anvilops.cluster.faq.link` | A link to more information about the cluster. | | | | `anvilops.nameOverride` | Override the name used in the `kubernetes.io/name` label. | | | | `anvilops.fullnameOverride` | Override the DNS label name used to create K8s resource names within the chart. | | | | `anvilops.securityContext` | The AnvilOps pod's security context. | | diff --git a/charts/anvilops/templates/anvilops/anvilops-deployment.yaml b/charts/anvilops/templates/anvilops/anvilops-deployment.yaml index 315d92c6..364fb3d4 100644 --- a/charts/anvilops/templates/anvilops/anvilops-deployment.yaml +++ b/charts/anvilops/templates/anvilops/anvilops-deployment.yaml @@ -241,6 +241,10 @@ spec: value: {{ join "," .Values.tenants.accessModes }} - name: APP_DOMAIN value: {{ .Values.anvilops.env.appDomain }} + - name: CNAME_DOMAIN + value: {{ .Values.anvilops.env.cnameDomain | quote }} + - name: ACME_SERVER_ADDRESS + value: {{ .Values.anvilops.env.acmeServerAddress | quote }} - name: HARBOR_PROJECT_NAME value: {{ .Values.anvilops.env.harborProjectName }} - name: CHART_REGISTRY_HOSTNAME diff --git a/charts/anvilops/templates/anvilops/anvilops-ingress.yaml b/charts/anvilops/templates/anvilops/anvilops-ingress.yaml index 36067196..b7be5a31 100644 --- a/charts/anvilops/templates/anvilops/anvilops-ingress.yaml +++ b/charts/anvilops/templates/anvilops/anvilops-ingress.yaml @@ -40,4 +40,20 @@ spec: number: {{ $.Values.anvilops.service.port }} {{- end }} {{- end }} + # Create an extra entry for custom domain name handling. Custom domains will have a CNAME that points to this domain. When the user + # visits a custom domain with a properly-configured CNAME, the Ingress controller will proxy the traffic to their app instead of here, + # but this is still necessary to reserve the name (if the ingress controller prevents duplicate hostnames) and to create a DNS record + # (in setups where DNS records aren't created for subdomains of the ingress controller's domain without an Ingress resource). + {{- with .Values.anvilops.env.cnameDomain }} + - host: {{ . | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "anvilops.fullname" $ }} + port: + number: {{ $.Values.anvilops.service.port }} + {{- end }} {{- end }} diff --git a/charts/anvilops/templates/jobs/renew-certificates.yaml b/charts/anvilops/templates/jobs/renew-certificates.yaml new file mode 100644 index 00000000..497531a7 --- /dev/null +++ b/charts/anvilops/templates/jobs/renew-certificates.yaml @@ -0,0 +1,96 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "anvilops.fullname" . }}-renew-certificates + labels: + {{- include "anvilops.commonLabels" . | nindent 4 }} +spec: + schedule: "0 0 * * *" # Run once daily at 12:00 AM local time + concurrencyPolicy: Replace + jobTemplate: + spec: + template: + spec: + restartPolicy: Never + containers: + - name: renew-certificates + image: {{ .Values.anvilops.image | quote }} + args: ["./src/jobs/renewCertificates.ts"] + env: + {{- if .Values.withRootCaCert }} + - name: NODE_EXTRA_CA_CERTS + value: /etc/ssl/certs/anvilops-tls.crt + {{- end }} + - name: CURRENT_NAMESPACE + value: {{ .Release.Namespace }} + - name: POSTGRES_USER + value: anvilops + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-credentials + key: password + - name: POSTGRES_DB + value: anvilops + - name: POSTGRES_HOSTNAME + value: anvilops-postgres + - name: FIELD_ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: postgres-credentials + key: field-encryption-key + - name: INGRESS_CLASS_NAME + value: {{ .Values.anvilops.apps.ingress.className }} + - name: APP_DOMAIN + value: {{ .Values.anvilops.env.appDomain }} + - name: ACME_SERVER_ADDRESS + value: {{ .Values.anvilops.env.acmeServerAddress | quote }} + {{- if .Values.rancher.enabled }} + {{- with .Values.rancher.baseURL }} + - name: RANCHER_BASE_URL + value: {{ . | quote }} + {{- end }} + - name: RANCHER_TOKEN + valueFrom: + secretKeyRef: + name: rancher-config + key: api-token + - name: SANDBOX_ID + valueFrom: + secretKeyRef: + name: rancher-config + key: sandbox-id + optional: true + {{- end }} + {{- with .Values.anvilops.env.loginType }} + - name: LOGIN_TYPE + value: {{ . | quote }} + {{- end }} + volumeMounts: + - name: ca-cert + mountPath: /etc/ssl/certs/anvilops-tls.crt + subPath: anvilops-tls.crt + readOnly: true + securityContext: + capabilities: + drop: [ALL] + runAsNonRoot: true + readOnlyRootFilesystem: true + runAsUser: 65532 + runAsGroup: 65532 + allowPrivilegeEscalation: false + resources: + requests: + cpu: 500m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi + volumes: + - name: ca-cert + secret: + secretName: root-ca-cert + optional: true + items: + - key: tls.crt + path: anvilops-tls.crt \ No newline at end of file diff --git a/charts/anvilops/values.yaml b/charts/anvilops/values.yaml index 6666b028..426e4aa1 100644 --- a/charts/anvilops/values.yaml +++ b/charts/anvilops/values.yaml @@ -24,6 +24,7 @@ anvilops: allowHelmDeployments: false otelEndpoint: "" otelServiceName: anvilops + acmeServerAddress: https://acme-v02.api.letsencrypt.org/directory extra: # If you need to add extra environment variables, put them here with the standard Kubernetes syntax. For example: # - name: DEBUG diff --git a/frontend/src/pages/app/AppView.tsx b/frontend/src/pages/app/AppView.tsx index ec7d0a9e..859b7ee5 100644 --- a/frontend/src/pages/app/AppView.tsx +++ b/frontend/src/pages/app/AppView.tsx @@ -8,6 +8,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useParams, useSearchParams } from "react-router-dom"; import { ConfigTab } from "./ConfigTab"; import { DangerZoneTab } from "./DangerZoneTab"; +import { DomainsTab } from "./DomainsTab"; import { FilesTab } from "./FilesTab"; import { LogsTab } from "./LogsTab"; import { OverviewTab } from "./OverviewTab"; @@ -95,6 +96,11 @@ export default function AppView() { Configuration + {isWorkloadConfig(app.config) && app.config.createIngress && ( + + Domains + + )} {settings.storageEnabled && (!!app.activeDeployment || app.config.appType === "helm") && ( <> @@ -126,6 +132,9 @@ export default function AppView() { + + + diff --git a/frontend/src/pages/app/DomainsTab.tsx b/frontend/src/pages/app/DomainsTab.tsx new file mode 100644 index 00000000..ea96c650 --- /dev/null +++ b/frontend/src/pages/app/DomainsTab.tsx @@ -0,0 +1,336 @@ +import { useAppConfig } from "@/components/AppConfigProvider"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { components } from "@/generated/openapi"; +import { api } from "@/lib/api"; +import { Check, Loader, Plus, Trash } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import type { App } from "./AppView"; + +type Domain = components["schemas"]["CustomDomain"]; + +export const DomainsTab = ({ app }: { app: App }) => { + if (app.config.appType !== "workload") throw new Error("Unexpected app type"); + const settings = useAppConfig(); + const { data, isLoading, refetch } = api.useQuery( + "get", + "/app/{appId}/domains", + { + params: { path: { appId: app.id } }, + }, + { + refetchInterval: (q) => { + const shouldPoll = q.state.data?.domains?.some( + (d) => d.status === "PENDING" || d.status === "GENERATING", + ); + return shouldPoll ? 1000 : false; + }, + }, + ); + + const domains = data?.domains ?? []; + + const included = settings.appDomain + ? app.config.subdomain + "." + new URL(settings.appDomain).host + : null; + + if (isLoading) { + return ; + } + + return ( + <> +

Custom Domains

+ {!!settings.appDomain && ( +
+
+

{included}

+ + Included + +
+

+ This domain is included with your AnvilOps app. It doesn't need + to be verified. +

+
+ )} +
+ {domains.map((domain) => ( + refetch()} + /> + ))} +
+ refetch()} /> + + ); +}; + +const RetryButton = ({ + domain, + invalidate, +}: { + domain: Domain; + invalidate: () => void; +}) => { + const { mutateAsync: retry, isPending } = api.useMutation( + "post", + "/app/{appId}/domains/{domainId}/retry", + ); + + return ( + + ); +}; + +const DOMAIN_RETRY_COOLDOWN = 1000 * 60 * 5; // Keep in sync with backend/src/service/retryCertGen.ts + +const DomainCard = ({ + domain, + invalidate, +}: { + domain: Domain; + invalidate: () => void; +}) => { + const { mutateAsync: verify, isPending } = api.useMutation( + "post", + "/app/{appId}/domains/{domainId}/verify", + ); + + const { mutateAsync: remove, isPending: isPendingDelete } = api.useMutation( + "delete", + "/app/{appId}/domains/{domainId}", + ); + + const canRetry = + domain.status === "ERROR" || + ((domain.status === "GENERATING" || domain.status === "PENDING") && + new Date().getTime() - new Date(domain.updatedAt).getTime() > + DOMAIN_RETRY_COOLDOWN); + + const [deleting, setDeleting] = useState(false); + + return ( +
+
+

{domain.domain}

+
+ {domain.status === "UNVERIFIED" ? ( + + Verification Needed + + ) : domain.status === "PENDING" ? ( + + Pending + + ) : domain.status === "GENERATING" ? ( + + Generating Certificate + + ) : domain.status === "GENERATED" ? ( + + Ready + + ) : domain.status === "ERROR" ? ( + + Error + + ) : ( + + Unknown Status + + )} + + + + + + + + Remove Domain + +

+ Are you sure you want to disconnect {domain.domain}{" "} + from your app? Users will no longer be able to access your app + at that domain until you re-register it, which will require + verifying the domain again. +

+
{ + e.preventDefault(); + await remove({ + params: { + path: { appId: domain.appId, domainId: domain.id }, + }, + }); + setDeleting(false); + invalidate(); + }} + > +
+ +
+
+
+
+
+
+ {domain.status === "UNVERIFIED" ? ( + <> +

+ Before you can use your domain, it must be verified. Add the + following DNS records and click Verify. +

+ + + + + + + + + + {domain.dnsRecords!.map((record, i) => ( + + + + + + ))} + +
TypeNameContent
{record.type}{record.name} + {record.content} +
+ +

+ DNS record changes may take a few minutes to propagate. +

+ + ) : domain.status === "PENDING" ? ( +

+ Your domain has been verified, and a TLS certificate has been + requested. +

+ ) : domain.status === "GENERATING" ? ( +

AnvilOps is generating a TLS certificate for your domain.

+ ) : domain.status === "GENERATED" ? ( +

+ Your domain has been verified and a certificate has been generated. It + is ready for use. +

+ ) : domain.status === "ERROR" ? ( +

There was an unexpected issue adding this domain to your app.

+ ) : null} + + {canRetry && } +
+ ); +}; + +const AddDomainModal = ({ + appId, + invalidate, +}: { + appId: number; + invalidate: () => void; +}) => { + const [open, setOpen] = useState(false); + + const { mutateAsync: addDomain, isPending } = api.useMutation( + "post", + "/app/{appId}/domains", + ); + + return ( + <> + + + + + + + Add Custom Domain + +

Use your own domain name with your AnvilOps app.

+
{ + const form = e.currentTarget; + e.preventDefault(); + const fd = new FormData(form); + const domain = fd.get("domain") as string; + if (!domain) return; + try { + await addDomain({ + params: { path: { appId } }, + body: { name: domain }, + }); + toast.success("Domain added!"); + setOpen(false); + invalidate(); + form.reset(); + } catch (e) { + console.error(e); + } + }} + > + +
+ +
+
+
+
+ + ); +}; diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index e922a2fa..2cbaec2d 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -13,6 +13,25 @@ tags: - name: app description: Operations about apps paths: + /.well-known/acme-challenge/{token}: + get: + description: "Fulfills ACME challenge requests to generate certificates for custom domains. Read more: https://letsencrypt.org/docs/challenge-types/#http-01-challenge" + operationId: acmeChallenge + parameters: + - name: token + in: path + required: true + description: A random string that uniquely identifies the challenge + schema: + type: string + responses: + "200": + description: Success + content: + text/plain: + schema: + type: string + description: The ASCII form of the key authorization (see https://datatracker.ietf.org/doc/html/rfc8555/#section-8.3) /liveness: get: operationId: livenessProbe @@ -1646,6 +1665,122 @@ paths: schema: description: A server-sent events (SSE) response. Each event will follow the format in '#/components/schemas/LogLine'. type: string + /app/{appId}/domains: + parameters: + - name: appId + in: path + required: true + schema: + type: integer + get: + operationId: listDomains + responses: + "200": + description: success + content: + application/json: + schema: + type: object + properties: + domains: + type: array + items: + $ref: "#/components/schemas/CustomDomain" + required: [domains] + post: + operationId: addDomain + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + required: [name] + responses: + "201": + description: Success + "404": + description: App not found + "409": + description: Custom domain already exists with that name + "400": + description: Domain name is invalid (malformed or uses a public suffix) + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + /app/{appId}/domains/{domainId}: + parameters: + - name: appId + in: path + required: true + schema: + type: integer + - name: domainId + in: path + required: true + schema: + type: integer + delete: + description: Removes a domain and its associated cerificates. + operationId: removeDomain + responses: + "204": + description: Success + "404": + description: App or domain not found + /app/{appId}/domains/{domainId}/verify: + description: Attempts to verify a domain by checking its DNS records for the ones AnvilOps expects. + parameters: + - name: appId + in: path + required: true + schema: + type: integer + - name: domainId + in: path + required: true + schema: + type: integer + post: + operationId: verifyDomain + responses: + "200": + description: Success + "404": + description: App or domain not found + "400": + description: Domain couldn't be verified because the DNS records are missing or incorrect + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + /app/{appId}/domains/{domainId}/retry: + description: If a certificate couldn't be generated for the domain, call this endpoint to retry the process. + parameters: + - name: appId + in: path + required: true + schema: + type: integer + - name: domainId + in: path + required: true + schema: + type: integer + post: + operationId: retryCertGen + responses: + "200": + description: Success + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" /logs/ingest: post: operationId: ingestLogs @@ -2747,3 +2882,38 @@ components: description: type: string required: [id, name, description] + CustomDomain: + type: object + properties: + id: + type: integer + appId: + type: integer + domain: + type: string + status: + type: string + enum: [UNVERIFIED, PENDING, GENERATING, GENERATED, ERROR] + updatedAt: + type: string + format: date-time + dnsRecords: + type: array + description: The DNS records that need to be added to the domain to verify it. Only present when status == UNVERIFIED. + items: + type: object + properties: + name: + type: string + type: + type: string + content: + type: string + required: [name, type, content] + required: [id, appId, domain, status, updatedAt] + CustomDomainInput: + type: object + properties: + domain: + type: string + required: [domain] diff --git a/swagger-ui/package-lock.json b/swagger-ui/package-lock.json index 5367e4e6..693845e1 100644 --- a/swagger-ui/package-lock.json +++ b/swagger-ui/package-lock.json @@ -1134,12 +1134,12 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } diff --git a/tilt/Tiltfile b/tilt/Tiltfile index 1632b37a..eb3fa31c 100644 --- a/tilt/Tiltfile +++ b/tilt/Tiltfile @@ -10,11 +10,15 @@ dotenv(fn='../backend/.env') separate_frontend_and_backend = True # Deploy a minimal instance of the Grafana LGTM + Alloy stack to collect logs, metrics, and traces from AnvilOps -enable_monitoring = True +enable_monitoring = False # Deploy Rancher locally with_rancher = False +# Deploy Pebble, a Let's Encrypt ACME-compatible server used to test certificate generation for custom domains (https://github.com/letsencrypt/pebble) +# Note: When using Pebble, you'll need to run `tilt/add-pebble-ca.sh` to get AnvilOps to trust Pebble's CA certificate. See the comments there for more info. +enable_pebble = True + # Requires kubeVersion <1.35.0-0 RANCHER_VERSION="v2.13.2" @@ -30,15 +34,18 @@ if separate_frontend_and_backend: ], only=["frontend", "openapi"] ) + flags = [ + "/usr/local/bin/node", + "--experimental-strip-types", + "--watch" + ] + if enable_monitoring: + flags.append("--require") + flags.append("/app/src/instrumentation.ts") docker_build( "anvilops/anvilops", "../", dockerfile="./anvilops-backend.Dockerfile", - entrypoint=[ - "/usr/local/bin/node", - "--experimental-strip-types", - "--require", "/app/src/instrumentation.ts", - "--watch" - ], + entrypoint=flags, live_update=[ sync("../backend/src", "/app/src"), run("touch /app/src/index.ts") # In some cases, Node doesn't detect file changes (i.e. the --watch flag doesn't work). This command seems to improve reliability of automatic restarts. @@ -97,6 +104,17 @@ k8s_yaml(secret_from_dict("image-pull-secret", inputs={'server': registry_host, # Hard-code these values so that the secret value doesn't drift from the value that Postgres used when creating the user k8s_yaml(secret_from_dict("postgres-credentials", inputs={'password': 'super-insecure-local-dev-password', 'field-encryption-key': '96WYSbPNEYi65gb8BRCzbf5ISIywNVV8Dm1rFCnRUFk='})) +helm_values = [] + +if enable_monitoring: + helm_values.append("anvilops.env.otelEndpoint=http://lgtm:4317") + +if enable_pebble: + # Use HTTPS to communicate with Pebble + helm_values.append("withRootCaCert=true") + # Send ACME (Let's Encrypt certificate generation) requests to the local Pebble server instead of the public staging server + helm_values.append("anvilops.env.acmeServerAddress=https://pebble:14000/dir") + yaml = helm( '../charts/anvilops', # The release name, equivalent to helm --name @@ -106,7 +124,7 @@ yaml = helm( # The values file to substitute into the chart. values=['./local-values.yaml'], # Values to override from the command-line - set=['anvilops.env.otelEndpoint=http://lgtm:4317'] if enable_monitoring else [] + set=helm_values ) parsed = decode_yaml_stream(yaml) @@ -127,7 +145,7 @@ for i, object in enumerate(parsed): # Alias Rancher hostname to the ingress controller ClusterIP hostAliases = object["spec"]["template"]["spec"].get("hostAliases", []) object["spec"]["template"]["spec"]["hostAliases"] = hostAliases + [{ "ip": ingress_clusterip, "hostnames": [ "rancher.127.0.0.1.nip.io" ] }] - + # Add an IN_TILT environment variable to redirect `localhost` registry lookups to the REGISTRY_HOSTNAME instead (see backend/src/lib/cluster/resources/logs.ts) object["spec"]["template"]["spec"]["containers"][0]["env"].append({"name": "IN_TILT", "value": "true"}) if separate_frontend_and_backend: @@ -197,7 +215,7 @@ helm_resource( '--set=controller.service.clusterIP=%s' % ingress_clusterip ], resource_deps=['ingress-nginx_repo'], - port_forwards=('3000:80','0.0.0.0:443:443') + port_forwards=('3000:80', '3001:443', '0.0.0.0:443:443') ) if enable_monitoring: @@ -215,3 +233,19 @@ if enable_monitoring: ) k8s_resource("lgtm", port_forwards=["3001:3000"]) + +if enable_pebble: + # Deploy Pebble + pebble_yaml = read_yaml_stream("pebble.manifest.yaml") + + for obj in pebble_yaml: + if obj["kind"] == "Deployment" and obj["metadata"]["name"] == "pebble": + containers = obj["spec"]["template"]["spec"]["containers"] + for c in containers: + if c["name"] == "challtestsrv": + c["args"].append("-defaultIPv4") + c["args"].append(ingress_clusterip) + + k8s_yaml(encode_yaml_stream(pebble_yaml)) + k8s_resource("pebble", port_forwards=["14000:14000", "15000:15000"]) + diff --git a/tilt/add-pebble-ca.sh b/tilt/add-pebble-ca.sh new file mode 100755 index 00000000..d287cfef --- /dev/null +++ b/tilt/add-pebble-ca.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -e + +# This script adds Pebble's CA certificate to the root-ca-cert Kubernetes Secret. +# We need to do this because Pebble runs on HTTPS only, and it uses a self-signed certificate. +# Without running this script, AnvilOps won't trust it, which will prevent it from generating certificates. + +# You only need to run this script if you're using Pebble to test generating certificates for custom domains in a local +# development environment. In production, you should use a real ACME server, which should be trusted by a common root CA. + +# After running this script, set `withRootCaCert` to `true` in `local-values.yaml`. +# If you're using Tilt, this last step will be done for you if you've set `enable_pebble` to `True` in the `Tiltfile`. + +SECRET_NAME="root-ca-cert" +NEW_CERT_FILE="pebble.minica.pem" +DATA_KEY="tls.crt" + +CWD="$(pwd)" +cd "$(mktemp -d)" + +wget "https://raw.githubusercontent.com/letsencrypt/pebble/refs/heads/main/test/certs/pebble.minica.pem" + +EXISTING_DATA="$(kubectl get secret "$SECRET_NAME" --ignore-not-found -o jsonpath="{.data['$DATA_KEY']}")" + +if [ -n "$EXISTING_DATA" ]; then + echo "$EXISTING_DATA" | base64 --decode > combined_chain.pem + # Add a newline to ensure the next cert starts on a new line + echo "" >> combined_chain.pem +else + # No existing certificate in the secret; start from a blank file + touch combined_chain.pem +fi + +# Append the new cert to the chain +cat "$NEW_CERT_FILE" >> combined_chain.pem + +kubectl create secret generic "$SECRET_NAME" \ + --from-file="$DATA_KEY"=combined_chain.pem \ + --dry-run=client -o yaml | kubectl apply -f - + +cd "$CWD" \ No newline at end of file diff --git a/tilt/anvilops-backend.Dockerfile b/tilt/anvilops-backend.Dockerfile index 5932e9b8..f7b73b04 100644 --- a/tilt/anvilops-backend.Dockerfile +++ b/tilt/anvilops-backend.Dockerfile @@ -49,7 +49,7 @@ COPY backend/package*.json . COPY backend/regclient-napi ./regclient-napi COPY --from=backend_deps /app/node_modules ./node_modules RUN --mount=type=cache,target=/root/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/root/.cache \ npm rebuild --foreground-scripts=true regclient-napi # Run the backend diff --git a/tilt/local-values.yaml b/tilt/local-values.yaml index 3df15a94..20273daa 100644 --- a/tilt/local-values.yaml +++ b/tilt/local-values.yaml @@ -7,6 +7,7 @@ anvilops: env: baseURL: http://localhost:3000 appDomain: http://127.0.0.1.nip.io:3000 # Any subdomains of this will resolve to 127.0.0.1 (see https://nip.io/ for more info) + cnameDomain: anvilops-cname.127.0.0.1.nip.io harborProjectName: anvilops allowedIdps: https://access-ci.org/idp loginType: shibboleth @@ -23,6 +24,7 @@ anvilops: registryProtocol: http otelEndpoint: "" # the Tiltfile overrides this if monitoring is enabled otelServiceName: anvilops + acmeServerAddress: https://acme-staging-v02.api.letsencrypt.org/directory cluster: name: "Local" faq: diff --git a/tilt/pebble.manifest.yaml b/tilt/pebble.manifest.yaml new file mode 100644 index 00000000..15c49741 --- /dev/null +++ b/tilt/pebble.manifest.yaml @@ -0,0 +1,110 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: pebble-config + namespace: default +data: + # Make HTTP-01 challenges use port 80, which ingress-nginx is already listening on, instead of the default 5002 + # The rest of this is copied from https://github.com/letsencrypt/pebble/blob/main/test/config/pebble-config.json + pebble-config.json: | + { + "pebble": { + "listenAddress": "0.0.0.0:14000", + "managementListenAddress": "0.0.0.0:15000", + "certificate": "test/certs/localhost/cert.pem", + "privateKey": "test/certs/localhost/key.pem", + "httpPort": 80, + "tlsPort": 5001, + "ocspResponderURL": "", + "externalAccountBindingRequired": false, + "domainBlocklist": ["blocked-domain.example"], + "retryAfter": { + "authz": 3, + "order": 5 + }, + "keyAlgorithm": "ecdsa", + "profiles": { + "default": { + "description": "The profile you know and love", + "validityPeriod": 7776000 + }, + "shortlived": { + "description": "A short-lived cert profile, without actual enforcement", + "validityPeriod": 518400 + } + } + } + } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: pebble + name: pebble +spec: + replicas: 1 + selector: + matchLabels: + app: pebble + strategy: {} + template: + metadata: + labels: + app: pebble + spec: + containers: + - image: ghcr.io/letsencrypt/pebble:latest + name: pebble + ports: + - containerPort: 15000 # Web-based management interface + - containerPort: 14000 # The ACME server address, used to request certificates + args: [ + "-config", + "/etc/pebble/pebble-config.json", + "-dnsserver", + "127.0.0.1:8053", + ] # Send DNS queries to the challtestsrv container + volumeMounts: + - name: config-volume + mountPath: /etc/pebble + readOnly: true + - image: ghcr.io/letsencrypt/pebble-challtestsrv:latest + name: challtestsrv + ports: + - containerPort: 5002 + - containerPort: 5003 + - containerPort: 8053 # DNS server address + - containerPort: 8055 + - containerPort: 8443 + args: # Disable all challenge types - we're only using this as a mock DNS server + - "-http01" + - "" + - "-https01" + - "" + - "-management" + - "" + - "-tlsalpn01" + - "" + - "-defaultIPv6" + - "" # Don't use IPv6 + # ... In the Tiltfile, the args will be updated to respond to all DNS queries with the IP address of the Ingress controller + volumes: + - name: config-volume + configMap: + name: pebble-config +--- +apiVersion: v1 +kind: Service +metadata: + name: pebble +spec: + selector: + app: pebble + ports: + - name: acme + port: 14000 + targetPort: 14000 + - name: management + port: 15000 + targetPort: 15000