diff --git a/README.md b/README.md index e7204a8..1635d95 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,6 @@ docker-compose up -d - **WebSocket MQTT**: `ws://localhost:8883/ws` - **HTTP API**: `http://localhost:8883/api` - 3. Connect with an MQTT client: > [!IMPORTANT] @@ -48,24 +47,25 @@ const client = mqtt.connect('ws://localhost:8883/ws') Backbone can be configured using environment variables: -| Variable | Description | Default | -|----------|-------------|---------| -| `ADMIN_TOKEN` | Admin token for API requests | `undefined` | -| `TOKEN_SECRET` | JWT signing secret for authentication | `undefined` | -| `K8S_ENABLED` | Enable Kubernetes operator mode | `false` | -| `HTTP_ENABLED` | Enable HTTP/WebSocket server | `true` | -| `HTTP_PORT` | HTTP server port | `8883` | -| `TCP_ENABLED` | Enable TCP MQTT server | `true` | -| `TCP_PORT` | TCP server port | `1883` | -| `OIDC_ENABLED` | OIDC discovery URL | `undefined` | -| `OIDC_DISCOVERY` | OIDC discovery URL | `undefined` | -| `OIDC_CLIENT_ID` | OIDC client ID | `undefined` | -| `OIDC_CLIENT_SECRET` | OIDC client secret | `undefined` | -| `OIDC_CLIENT_SECRET` | OIDC client secret | `undefined` | -| `OIDC_GROUP_FIELD` | JWT field for reading groups | `groups` | -| `OIDC_ADMIN_GROUP` | JWT group for admins | `undefined` | -| `OIDC_WRITER_GROUP` | JWT group with publish access to queue | `undefined` | -| `OIDC_READER_GROUP` | JWT group with read-only access to queue | `undefined` | +| Variable | Description | Default | +| -------------------- | ---------------------------------------- | ----------- | +| `ADMIN_TOKEN` | Admin token for API requests | `undefined` | +| `JWT_SECRET` | JWT signing secret for authentication | `undefined` | +| `K8S_ENABLED` | Enable Kubernetes operator mode | `false` | +| `WS_ENABLED` | Enable WebSocket MQTT server | `false` | +| `API_ENABLED` | Enable HTTP API | `false` | +| `HTTP_PORT` | HTTP server port | `8883` | +| `TCP_ENABLED` | Enable TCP MQTT server | `false` | +| `TCP_PORT` | TCP server port | `1883` | +| `OIDC_ENABLED` | OIDC discovery URL | `false` | +| `OIDC_DISCOVERY` | OIDC discovery URL | `undefined` | +| `OIDC_CLIENT_ID` | OIDC client ID | `undefined` | +| `OIDC_CLIENT_SECRET` | OIDC client secret | `undefined` | +| `OIDC_CLIENT_SECRET` | OIDC client secret | `undefined` | +| `OIDC_GROUP_FIELD` | JWT field for reading groups | `groups` | +| `OIDC_ADMIN_GROUP` | JWT group for admins | `undefined` | +| `OIDC_WRITER_GROUP` | JWT group with publish access to queue | `undefined` | +| `OIDC_READER_GROUP` | JWT group with read-only access to queue | `undefined` | ### Example Configuration @@ -130,9 +130,9 @@ Access control is defined using statement-based policies similar to AWS IAM: ```yaml statements: - - effect: allow # or "deny" - resources: ['*'] # MQTT topic patterns - actions: ['*'] # MQTT actions + - effect: allow # or "deny" + resources: ['*'] # MQTT topic patterns + actions: ['*'] # MQTT actions ``` #### MQTT Actions @@ -159,6 +159,7 @@ statements: ### HTTP API The HTTP API provides management endpoints for: + - Client management - Topic configuration - Broker statistics diff --git a/package.json b/package.json index 01ee0f1..2bd9037 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "type": "module", "main": "dist/exports.js", "scripts": { - "dev": "node --no-warnings --watch src/start.ts", + "dev": "node --no-warnings --watch src/dev.ts", "test:lint": "eslint", "build": "tsc --build", "test:unit": "vitest --run --passWithNoTests", @@ -41,13 +41,18 @@ "#root/*": "./src/*" }, "dependencies": { + "@fastify/sensible": "^6.0.3", + "@fastify/swagger": "^9.5.2", "@fastify/websocket": "^11.2.0", "@kubernetes/client-node": "^1.4.0", + "@scalar/fastify-api-reference": "^1.38.1", "aedes": "^0.51.3", + "aedes-packet": "^3.0.0", "aedes-persistence": "^10.2.2", "ajv": "^8.17.1", "better-sqlite3": "^12.4.1", "fastify": "^5.6.1", + "fastify-type-provider-zod": "^6.0.0", "jsonwebtoken": "^9.0.2", "knex": "^3.1.0", "micromatch": "^4.0.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ba243f..ce77a76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,15 +8,27 @@ importers: .: dependencies: + '@fastify/sensible': + specifier: ^6.0.3 + version: 6.0.3 + '@fastify/swagger': + specifier: ^9.5.2 + version: 9.5.2 '@fastify/websocket': specifier: ^11.2.0 version: 11.2.0 '@kubernetes/client-node': specifier: ^1.4.0 version: 1.4.0 + '@scalar/fastify-api-reference': + specifier: ^1.38.1 + version: 1.38.1 aedes: specifier: ^0.51.3 version: 0.51.3 + aedes-packet: + specifier: ^3.0.0 + version: 3.0.0 aedes-persistence: specifier: ^10.2.2 version: 10.2.2 @@ -29,6 +41,9 @@ importers: fastify: specifier: ^5.6.1 version: 5.6.1 + fastify-type-provider-zod: + specifier: ^6.0.0 + version: 6.0.0(@fastify/swagger@9.5.2)(fastify@5.6.1)(openapi-types@12.1.3)(zod@4.1.12) jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 @@ -71,7 +86,7 @@ importers: version: 8.18.1 '@vitest/coverage-v8': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.7.2)) + version: 3.2.4(vitest@3.2.4(@types/node@24.7.2)(yaml@2.8.1)) eslint: specifier: 9.37.0 version: 9.37.0 @@ -101,7 +116,7 @@ importers: version: 8.46.1(eslint@9.37.0)(typescript@5.9.3) vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@24.7.2) + version: 3.2.4(@types/node@24.7.2)(yaml@2.8.1) packages: @@ -350,6 +365,12 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/sensible@6.0.3': + resolution: {integrity: sha512-Iyn8698hp/e5+v8SNBBruTa7UfrMEP52R16dc9jMpqSyEcPsvWFQo+R6WwHCUnJiLIsuci2ZoEZ7ilrSSCPIVg==} + + '@fastify/swagger@9.5.2': + resolution: {integrity: sha512-8e8w/LItg/cF6IR/hYKtnt+E0QImees5o3YWJsTLxaIk+tzNUEc6Z2Ursi4oOHWwUlKjUCnV6yh5z5ZdxvlsWA==} + '@fastify/websocket@11.2.0': resolution: {integrity: sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==} @@ -409,6 +430,10 @@ packages: '@kubernetes/client-node@1.4.0': resolution: {integrity: sha512-Zge3YvF7DJi264dU1b3wb/GmzR99JhUpqTvp+VGHfwZT+g7EOOYNScDJNZwXy9cszyIGPIs0VHr+kk8e95qqrA==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -683,6 +708,38 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@scalar/core@0.3.20': + resolution: {integrity: sha512-bIlrePx41pSvjDcaJPa9YVVhbSm0N9SKQm2Fzl489S0bUVToyXIQtMFVR4i+BmXGjOcATm/66ELW4vdXRjHoRA==} + engines: {node: '>=20'} + + '@scalar/fastify-api-reference@1.38.1': + resolution: {integrity: sha512-olRjyMn45gTB1tYCXjjQMhAeku8Z46rlPNYYAg9BU+dcfcm1miuxT0cYhB2U9pI5rZtoh2j305M7FG7ShlY2hQ==} + engines: {node: '>=20'} + + '@scalar/helpers@0.0.12': + resolution: {integrity: sha512-4NDmHShyi1hrVRsJCdRZT/FIpy+/5PFbVbQLRYX/pjpu5cYqHBj9s6n5RI6gGDXEBHAIFi63g9FC6Isgr66l1Q==} + engines: {node: '>=20'} + + '@scalar/json-magic@0.6.1': + resolution: {integrity: sha512-HJMPY5dUU3EXVS4EkjAFXo+uCrby/YFu/gljKDQnhYWRy5zQ0sJWrOEDcHS8nLoJRCIRD5tiVpCxnUnSb6OoAQ==} + engines: {node: '>=20'} + + '@scalar/openapi-parser@0.22.3': + resolution: {integrity: sha512-5Znbx9HVJb7EV9EJXJrUj+cs116QIBwX/hxkyaiLaaDL2w5S+z1rjtV+d0Jv7382FCtzAtfv/9llVuxZYPVqXA==} + engines: {node: '>=20'} + + '@scalar/openapi-types@0.5.0': + resolution: {integrity: sha512-HJBcLa+/mPP+3TCcQngj/iW5UqynRosOQdEETXjmdy6Ngw8wBjwIcT6C86J5jufJ6sI8++HYnt+e7pAvp5FO6A==} + engines: {node: '>=20'} + + '@scalar/openapi-upgrader@0.1.3': + resolution: {integrity: sha512-iROhcgy3vge6zsviPtoTLHale0nYt1PLhuMmJweQwJ0U23ZYyYhV5xgHtAd0OBEXuqT6rjYbJFvKOJZmJOwpNQ==} + engines: {node: '>=20'} + + '@scalar/types@0.3.2': + resolution: {integrity: sha512-+X10CCvG57nAqYbTGteiSzRFQcMYm7DLfCRMeEfiWQ9Bq2ladat17XsMSvkvwcfpOSlsoepWf3P5dErERUSOQQ==} + engines: {node: '>=20'} + '@types/braces@3.0.5': resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} @@ -870,6 +927,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -1253,6 +1318,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1506,9 +1575,20 @@ packages: resolution: {integrity: sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==} engines: {node: '>=0.10.0'} + fastify-plugin@4.5.1: + resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} + fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + fastify-type-provider-zod@6.0.0: + resolution: {integrity: sha512-Bz+Qll2XuvvueHz0yhcr67V/43q1VecSyIqZm+P8OL8KZHznUXECZXkuwQePR5b6fWY/kzhhadmgNs9dB/Nifg==} + peerDependencies: + '@fastify/swagger': '>=9.5.1' + fastify: ^5.0.0 + openapi-types: ^12.1.3 + zod: '>=4.1.5' + fastify@5.6.1: resolution: {integrity: sha512-WjjlOciBF0K8pDUPZoGPhqhKrQJ02I8DKaDIfO51EL0kbSMwQFl85cRwhOvmSDWoukNOdTo27gLN549pLCcH7Q==} @@ -1568,6 +1648,10 @@ packages: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -1623,6 +1707,9 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1693,6 +1780,10 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -1930,6 +2021,10 @@ packages: json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -1956,6 +2051,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + jsonwebtoken@9.0.2: resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} engines: {node: '>=12', npm: '>=6'} @@ -1997,6 +2096,10 @@ packages: tedious: optional: true + leven@4.1.0: + resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2070,6 +2173,10 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + mem@8.1.1: resolution: {integrity: sha512-qFCFUDs7U3b8mBDPyz5EToEKoAkgCzqquIgi9nkkR9bixxOVOre+09lbuH7+9Kn2NFpm56M3GUWVbU2hQgdACA==} engines: {node: '>=10'} @@ -2148,6 +2255,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.5: + resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==} + engines: {node: ^18 || >=20} + hasBin: true + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -2224,6 +2336,9 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + openid-client@6.8.1: resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==} @@ -2598,6 +2713,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2681,6 +2799,10 @@ packages: stacktracey@2.1.8: resolution: {integrity: sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==} + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -2771,6 +2893,10 @@ packages: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -2835,6 +2961,10 @@ packages: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -2865,6 +2995,14 @@ packages: resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==} engines: {node: '>=8'} + type-fest@5.0.0: + resolution: {integrity: sha512-GeJop7+u7BYlQ6yQCAY1nBQiRSHR+6OdCEtd8Bwp9a3NK3+fWAVjOaPKJDteB9f6cIJ0wt4IfnScjLG450EpXA==} + engines: {node: '>=20'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -2924,6 +3062,10 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -3101,10 +3243,23 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + yaml@2.8.0: + resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} + engines: {node: '>= 14.6'} + hasBin: true + + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.1.11: + resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + zod@4.1.12: resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} @@ -3285,6 +3440,26 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.2.0 + '@fastify/sensible@6.0.3': + dependencies: + '@lukeed/ms': 2.0.2 + dequal: 2.0.3 + fastify-plugin: 5.1.0 + forwarded: 0.2.0 + http-errors: 2.0.0 + type-is: 1.6.18 + vary: 1.1.2 + + '@fastify/swagger@9.5.2': + dependencies: + fastify-plugin: 5.1.0 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.8.1 + transitivePeerDependencies: + - supports-color + '@fastify/websocket@11.2.0': dependencies: duplexify: 4.1.3 @@ -3367,6 +3542,8 @@ snapshots: - supports-color - utf-8-validate + '@lukeed/ms@2.0.2': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3686,6 +3863,52 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@scalar/core@0.3.20': + dependencies: + '@scalar/types': 0.3.2 + + '@scalar/fastify-api-reference@1.38.1': + dependencies: + '@scalar/core': 0.3.20 + '@scalar/openapi-parser': 0.22.3 + '@scalar/openapi-types': 0.5.0 + fastify-plugin: 4.5.1 + github-slugger: 2.0.0 + + '@scalar/helpers@0.0.12': {} + + '@scalar/json-magic@0.6.1': + dependencies: + '@scalar/helpers': 0.0.12 + yaml: 2.8.0 + + '@scalar/openapi-parser@0.22.3': + dependencies: + '@scalar/json-magic': 0.6.1 + '@scalar/openapi-types': 0.5.0 + '@scalar/openapi-upgrader': 0.1.3 + ajv: 8.17.1 + ajv-draft-04: 1.0.0(ajv@8.17.1) + ajv-formats: 3.0.1(ajv@8.17.1) + jsonpointer: 5.0.1 + leven: 4.1.0 + yaml: 2.8.0 + + '@scalar/openapi-types@0.5.0': + dependencies: + zod: 4.1.11 + + '@scalar/openapi-upgrader@0.1.3': + dependencies: + '@scalar/openapi-types': 0.5.0 + + '@scalar/types@0.3.2': + dependencies: + '@scalar/openapi-types': 0.5.0 + nanoid: 5.1.5 + type-fest: 5.0.0 + zod: 4.1.11 + '@types/braces@3.0.5': {} '@types/chai@5.2.2': @@ -3831,7 +4054,7 @@ snapshots: '@typescript-eslint/types': 8.46.1 eslint-visitor-keys: 4.2.1 - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.7.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.7.2)(yaml@2.8.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -3846,7 +4069,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.7.2) + vitest: 3.2.4(@types/node@24.7.2)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -3858,13 +4081,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.10(@types/node@24.7.2))': + '@vitest/mocker@3.2.4(vite@7.1.10(@types/node@24.7.2)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.19 optionalDependencies: - vite: 7.1.10(@types/node@24.7.2) + vite: 7.1.10(@types/node@24.7.2)(yaml@2.8.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -3947,6 +4170,10 @@ snapshots: agent-base@7.1.4: {} + ajv-draft-04@1.0.0(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -4353,6 +4580,8 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -4719,8 +4948,18 @@ snapshots: dependencies: reusify: 1.1.0 + fastify-plugin@4.5.1: {} + fastify-plugin@5.1.0: {} + fastify-type-provider-zod@6.0.0(@fastify/swagger@9.5.2)(fastify@5.6.1)(openapi-types@12.1.3)(zod@4.1.12): + dependencies: + '@fastify/error': 4.2.0 + '@fastify/swagger': 9.5.2 + fastify: 5.6.1 + openapi-types: 12.1.3 + zod: 4.1.12 + fastify@5.6.1: dependencies: '@fastify/ajv-compiler': 4.0.3 @@ -4799,6 +5038,8 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + forwarded@0.2.0: {} + fs-constants@1.0.0: {} fsevents@2.3.3: @@ -4858,6 +5099,8 @@ snapshots: github-from-package@0.0.0: {} + github-slugger@2.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -4918,6 +5161,14 @@ snapshots: html-escaper@2.0.2: {} + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + human-signals@2.1.0: {} hyperid@3.3.0: @@ -5138,6 +5389,14 @@ snapshots: dependencies: dequal: 2.0.3 + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3 + fast-uri: 3.1.0 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -5158,6 +5417,8 @@ snapshots: '@jsep-plugin/regex': 1.0.4(jsep@1.4.0) jsep: 1.4.0 + jsonpointer@5.0.1: {} + jsonwebtoken@9.0.2: dependencies: jws: 3.2.2 @@ -5208,6 +5469,8 @@ snapshots: transitivePeerDependencies: - supports-color + leven@4.1.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -5276,6 +5539,8 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@0.3.0: {} + mem@8.1.1: dependencies: map-age-cleaner: 0.1.3 @@ -5366,6 +5631,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.5: {} + napi-build-utils@2.0.0: {} natural-compare@1.4.0: {} @@ -5446,6 +5713,8 @@ snapshots: dependencies: mimic-fn: 2.1.0 + openapi-types@12.1.3: {} + openid-client@6.8.1: dependencies: jose: 6.1.0 @@ -5843,6 +6112,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -5933,6 +6204,8 @@ snapshots: as-table: 1.0.55 get-source: 2.0.12 + statuses@2.0.1: {} + std-env@3.10.0: {} stop-iteration-iterator@1.1.0: @@ -6031,6 +6304,8 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 + tagged-tag@1.0.0: {} + tar-fs@2.1.4: dependencies: chownr: 1.1.4 @@ -6112,6 +6387,8 @@ snapshots: toad-cache@3.7.0: {} + toidentifier@1.0.1: {} + tr46@0.0.3: {} ts-api-utils@2.1.0(typescript@5.9.3): @@ -6139,6 +6416,15 @@ snapshots: type-fest@0.6.0: {} + type-fest@5.0.0: + dependencies: + tagged-tag: 1.0.0 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -6212,13 +6498,15 @@ snapshots: uuid@8.3.2: {} - vite-node@3.2.4(@types/node@24.7.2): + vary@1.1.2: {} + + vite-node@3.2.4(@types/node@24.7.2)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.10(@types/node@24.7.2) + vite: 7.1.10(@types/node@24.7.2)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -6233,7 +6521,7 @@ snapshots: - tsx - yaml - vite@7.1.10(@types/node@24.7.2): + vite@7.1.10(@types/node@24.7.2)(yaml@2.8.1): dependencies: esbuild: 0.25.10 fdir: 6.5.0(picomatch@4.0.3) @@ -6244,12 +6532,13 @@ snapshots: optionalDependencies: '@types/node': 24.7.2 fsevents: 2.3.3 + yaml: 2.8.1 - vitest@3.2.4(@types/node@24.7.2): + vitest@3.2.4(@types/node@24.7.2)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.10(@types/node@24.7.2)) + '@vitest/mocker': 3.2.4(vite@7.1.10(@types/node@24.7.2)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -6267,8 +6556,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.10(@types/node@24.7.2) - vite-node: 3.2.4(@types/node@24.7.2) + vite: 7.1.10(@types/node@24.7.2)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.7.2)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.7.2 @@ -6431,6 +6720,12 @@ snapshots: xtend@4.0.2: {} + yaml@2.8.0: {} + + yaml@2.8.1: {} + yocto-queue@0.1.0: {} + zod@4.1.11: {} + zod@4.1.12: {} diff --git a/src/api/api.ts b/src/api/api.ts index 267aa52..5e25d7b 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,8 +1,34 @@ import { type FastifyPluginAsync } from 'fastify'; +import { manageEndpoints } from './endpoints/endpoints.manage.ts'; +import { authPlugin } from './plugins/plugins.auth.ts'; +import { messageEndpoints } from './endpoints/endpoints.message.ts'; +import { z } from 'zod'; const api: FastifyPluginAsync = async (fastify) => { - fastify.get('/healthz', () => { - return { status: 'ok' }; + fastify.route({ + method: 'get', + url: '/health', + schema: { + operationId: 'health.get', + summary: 'Get health status', + tags: ['system'], + response: { + 200: z.object({ + status: z.literal('ok'), + }), + }, + }, + handler: () => { + return { status: 'ok' }; + }, + }); + await authPlugin(fastify, {}); + + await fastify.register(manageEndpoints, { + prefix: '/manage', + }); + await fastify.register(messageEndpoints, { + prefix: '/message', }); }; diff --git a/src/api/endpoints/endpoints.manage.ts b/src/api/endpoints/endpoints.manage.ts new file mode 100644 index 0000000..9b60a49 --- /dev/null +++ b/src/api/endpoints/endpoints.manage.ts @@ -0,0 +1,45 @@ +import { JwtAuth } from '#root/auth/auth.jwt.ts'; +import { statementSchema } from '#root/auth/auth.schemas.ts'; +import { Config } from '#root/config/config.ts'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { z } from 'zod'; + +const manageEndpoints: FastifyPluginAsyncZod = async (fastify) => { + const config = fastify.services.get(Config); + + if (config.jwtSecret) { + fastify.route({ + method: 'post', + url: '/jwt', + schema: { + operationId: 'manage.jwt.post', + summary: 'Generate a JWT', + tags: ['manage'], + body: z.object({ + exp: z.number().optional(), + statements: z.array(statementSchema), + }), + response: { + 200: z.object({ + jwt: z.string(), + }), + }, + }, + handler: async (req, reply) => { + if ( + !req.session.validate({ + action: 'mgmt:generate-jwt', + resource: 'mgmt/', + }) + ) { + throw reply.unauthorized('not allowed'); + } + const jwtAuth = fastify.services.get(JwtAuth); + const jwt = jwtAuth.generate(req.body); + reply.send({ jwt }); + }, + }); + } +}; + +export { manageEndpoints }; diff --git a/src/api/endpoints/endpoints.message.ts b/src/api/endpoints/endpoints.message.ts new file mode 100644 index 0000000..2d8915a --- /dev/null +++ b/src/api/endpoints/endpoints.message.ts @@ -0,0 +1,62 @@ +import { Config } from '#root/config/config.ts'; +import { MqttServer } from '#root/server/server.ts'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { z } from 'zod'; + +const messageEndpoints: FastifyPluginAsyncZod = async (fastify) => { + const config = fastify.services.get(Config); + + if (config.jwtSecret) { + fastify.route({ + method: 'post', + url: '', + schema: { + summary: 'Post a message to the bus', + operationId: 'message.post', + tags: ['message'], + body: z.object({ + topic: z.string(), + dup: z.boolean(), + qos: z.union([z.literal(0), z.literal(1), z.literal(2)]), + retain: z.boolean(), + payload: z.string(), + }), + response: { + 200: z.object({ + success: z.literal(true), + }), + }, + }, + handler: async (req, reply) => { + if ( + !req.session.validate({ + action: 'mqtt:publish', + resource: 'mgmt:', + }) + ) { + throw reply.unauthorized('not allowed'); + } + const server = fastify.services.get(MqttServer); + + await new Promise((resolve, reject) => { + server.bus.publish( + { + ...req.body, + cmd: 'publish', + payload: Buffer.from(req.body.payload, 'base64'), + }, + (err) => { + if (err) { + return reject(err); + } + resolve(); + }, + ); + }); + reply.send({ success: true }); + }, + }); + } +}; + +export { messageEndpoints }; diff --git a/src/api/extensions.d.ts b/src/api/extensions.d.ts new file mode 100644 index 0000000..687e91e --- /dev/null +++ b/src/api/extensions.d.ts @@ -0,0 +1,14 @@ +import type { Session } from '#root/services/sessions/sessions.session.ts'; +import type { Services } from '#root/utils/services.ts'; +import 'fastify'; +declare module 'fastify' { + // eslint-disable-next-line + export interface FastifyInstance { + services: Services; + } + + // eslint-disable-next-line + export interface FastifyRequest { + session: Session; + } +} diff --git a/src/api/plugins/plugins.auth.ts b/src/api/plugins/plugins.auth.ts new file mode 100644 index 0000000..aacd613 --- /dev/null +++ b/src/api/plugins/plugins.auth.ts @@ -0,0 +1,27 @@ +import { SessionProvider } from '#root/services/sessions/sessions.provider.ts'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; + +const authPlugin: FastifyPluginAsyncZod = async (fastify) => { + fastify.addHook('onRequest', async (req, reply) => { + const authProvider = req.headers['x-auth-provider']; + if (!authProvider || Array.isArray(authProvider)) { + throw reply.unauthorized('missing x-auth-provider header'); + } + const authorization = req.headers.authorization; + if (!authorization) { + throw reply.unauthorized('missing authorization header'); + } + const [type, token] = authorization.split(' '); + if (type.toLowerCase() !== 'bearer') { + throw reply.unauthorized('only bearer tokens are allowed'); + } + if (!token) { + throw reply.unauthorized('missing token'); + } + const sessionProvider = fastify.services.get(SessionProvider); + const session = await sessionProvider.get(authProvider, token); + req.session = session; + }); +}; + +export { authPlugin }; diff --git a/src/auth/auth.admin.ts b/src/auth/auth.admin.ts index f657f11..be99fb0 100644 --- a/src/auth/auth.admin.ts +++ b/src/auth/auth.admin.ts @@ -1,15 +1,7 @@ import type { Services } from '#root/utils/services.ts'; import { Config } from '#root/config/config.ts'; -import type { Statement } from './auth.schemas.ts'; import type { AuthProvider } from './auth.provider.ts'; - -const adminStatements: Statement[] = [ - { - effect: 'allow', - resources: ['**'], - actions: ['**'], - }, -]; +import { ADMIN_STATEMENTS } from './auth.consts.ts'; class AdminAuth implements AuthProvider { #services: Services; @@ -24,7 +16,7 @@ class AdminAuth implements AuthProvider { throw new Error('Invalid admin token'); } return { - statements: adminStatements, + statements: ADMIN_STATEMENTS, }; }; } diff --git a/src/auth/auth.consts.ts b/src/auth/auth.consts.ts new file mode 100644 index 0000000..05195c7 --- /dev/null +++ b/src/auth/auth.consts.ts @@ -0,0 +1,25 @@ +import type { Statement } from './auth.schemas.ts'; + +const ADMIN_STATEMENTS: Statement[] = [ + { + effect: 'allow', + resources: ['**'], + actions: ['**'], + }, +]; +const WRITER_STATEMENTS: Statement[] = [ + { + effect: 'allow', + resources: ['**'], + actions: ['mqtt:**'], + }, +]; +const READER_STATEMENTS: Statement[] = [ + { + effect: 'allow', + resources: ['**'], + actions: ['mqtt:read', 'mqtt:subscribe'], + }, +]; + +export { ADMIN_STATEMENTS, WRITER_STATEMENTS, READER_STATEMENTS }; diff --git a/src/auth/auth.jwt.ts b/src/auth/auth.jwt.ts index 50f7f9a..ce90968 100644 --- a/src/auth/auth.jwt.ts +++ b/src/auth/auth.jwt.ts @@ -8,6 +8,7 @@ import type { Services } from '#root/utils/services.ts'; import { Config } from '#root/config/config.ts'; const tokenBodySchema = z.object({ + exp: z.number().optional(), statements: z.array(statementSchema), }); @@ -32,11 +33,11 @@ class JwtAuth implements AuthProvider { public getAccess = async (token: string) => { const config = this.#services.get(Config); - const { jwtSecret: tokenSecret } = config; - if (!tokenSecret) { + const { jwtSecret } = config; + if (!jwtSecret) { throw new Error('Token secret does not exist'); } - const data = jwt.verify(token, tokenSecret); + const data = jwt.verify(token, jwtSecret); const parsed = tokenBodySchema.parse(data); return parsed; }; diff --git a/src/auth/auth.oidc.ts b/src/auth/auth.oidc.ts index fd9c040..92e5650 100644 --- a/src/auth/auth.oidc.ts +++ b/src/auth/auth.oidc.ts @@ -5,28 +5,7 @@ import type { AuthProvider } from './auth.provider.ts'; import type { Services } from '#root/utils/services.ts'; import { Config } from '#root/config/config.ts'; - -const adminStatements: Statement[] = [ - { - effect: 'allow', - resources: ['**'], - actions: ['**'], - }, -]; -const writerStatements: Statement[] = [ - { - effect: 'allow', - resources: ['**'], - actions: ['mqtt:**'], - }, -]; -const readerStatements: Statement[] = [ - { - effect: 'allow', - resources: ['**'], - actions: ['mqtt:read', 'mqtt:subscribe'], - }, -]; +import { ADMIN_STATEMENTS, READER_STATEMENTS, WRITER_STATEMENTS } from './auth.consts.ts'; class OidcAuth implements AuthProvider { #services: Services; @@ -49,13 +28,13 @@ class OidcAuth implements AuthProvider { const groups = data[config.oidc.groupField]; if (Array.isArray(groups)) { if (config.oidc.groups.admin && groups.includes(config.oidc.groups.admin)) { - statements = adminStatements; + statements = ADMIN_STATEMENTS; } if (config.oidc.groups.writer && groups.includes(config.oidc.groups.writer)) { - statements = writerStatements; + statements = WRITER_STATEMENTS; } if (config.oidc.groups.reader && groups.includes(config.oidc.groups.reader)) { - statements = readerStatements; + statements = READER_STATEMENTS; } } return { diff --git a/src/backbone.ts b/src/backbone.ts index caf869e..7436380 100644 --- a/src/backbone.ts +++ b/src/backbone.ts @@ -45,8 +45,7 @@ class Backbone { await this.k8s.setup(); this.sessionProvider.register('k8s', this.#services.get(K8sAuth)); } - if (this.config.http.enabled) { - console.log('starting http'); + if (this.config.ws.enabled || this.config.api.enabled) { const http = await this.server.getHttpServer(); http.listen({ port: this.config.http.port, host: '0.0.0.0' }); } diff --git a/src/config/config.ts b/src/config/config.ts index cbdea15..f0ae998 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,6 +1,6 @@ class Config { public get jwtSecret() { - return process.env.TOKEN_SECRET; + return process.env.JWT_SECRET; } public get adminToken() { @@ -38,14 +38,26 @@ class Config { } public get http() { - const enabled = (process.env.HTTP_ENABLED = 'true'); const port = process.env.HTTP_PORT ? parseInt(process.env.HTTP_PORT) : 8883; return { - enabled, port, }; } + public get api() { + const enabled = process.env.API_ENABLED === 'true'; + return { + enabled, + }; + } + + public get ws() { + const enabled = process.env.WS_ENABLED === 'true'; + return { + enabled, + }; + } + public get tcp() { const enabled = (process.env.TCP_ENABLED = 'true'); const port = process.env.TCP_PORT ? parseInt(process.env.TCP_PORT) : 1883; diff --git a/src/dev.ts b/src/dev.ts new file mode 100644 index 0000000..e3a2e63 --- /dev/null +++ b/src/dev.ts @@ -0,0 +1,9 @@ +import { Backbone } from './backbone.ts'; +process.env.JWT_SECRET = 'test'; +process.env.ADMIN_TOKEN = 'admin'; +process.env.API_ENABLED = 'true'; + +const backbone = new Backbone(); +await backbone.start(); + +console.log('started'); diff --git a/src/server/server.ts b/src/server/server.ts index 9e27782..d994990 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,6 +1,15 @@ import tcp from 'node:net'; import type { IncomingMessage } from 'node:http'; +import swagger from '@fastify/swagger'; +import type { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { + jsonSchemaTransform, + createJsonSchemaTransform, + serializerCompiler, + validatorCompiler, +} from 'fastify-type-provider-zod'; +import scalar from '@scalar/fastify-api-reference'; import { type AuthenticateHandler, type AuthorizeForwardHandler, @@ -19,6 +28,8 @@ import { TopicsHandler } from '#root/topics/topics.handler.ts'; import type { Services } from '#root/utils/services.ts'; import { Session } from '#root/services/sessions/sessions.session.ts'; import { SessionProvider } from '#root/services/sessions/sessions.provider.ts'; +import fastifySensible from '@fastify/sensible'; +import { Config } from '#root/config/config.ts'; type Aedes = ReturnType; @@ -52,6 +63,10 @@ class MqttServer { }); } + public get bus() { + return this.#server; + } + #authenticate: AuthenticateHandler = async (client, username, password, callback) => { try { if (!username || !password) { @@ -112,14 +127,51 @@ class MqttServer { #setupHttpServer = async () => { const http = fastify({}); - await http.register(fastifyWebSocket); - http.get('/ws', { websocket: true }, (socket, req) => { - const stream = createWebSocketStream(socket); - this.#server.handle(stream, req as unknown as IncomingMessage); - }); - await http.register(api, { - prefix: '/api', - }); + const config = this.#services.get(Config); + if (config.api.enabled) { + http.decorate('services', this.#services); + http.setValidatorCompiler(validatorCompiler); + http.setSerializerCompiler(serializerCompiler); + await http.register(fastifyWebSocket); + await http.register(fastifySensible); + await http.register(swagger, { + openapi: { + info: { + title: 'Backbone', + version: '1.0.0', + }, + components: { + securitySchemes: { + authProviderHeader: { + type: 'apiKey', + name: 'X-Auth-Provider', + in: 'header', + }, + bearerAuth: { + type: 'http', + scheme: 'bearer', + }, + }, + }, + security: [{ bearerAuth: [], authProviderHeader: [] }], + }, + transform: jsonSchemaTransform, + }); + await http.register(scalar, { + routePrefix: '/docs', + }); + await http.register(api, { + prefix: '/api', + }); + } + if (config.ws.enabled) { + http.get('/ws', { websocket: true }, (socket, req) => { + const stream = createWebSocketStream(socket); + this.#server.handle(stream, req as unknown as IncomingMessage); + }); + } + await http.ready(); + http.swagger(); return http; }; diff --git a/src/services/sessions/sessions.provider.ts b/src/services/sessions/sessions.provider.ts index e692826..c89c39f 100644 --- a/src/services/sessions/sessions.provider.ts +++ b/src/services/sessions/sessions.provider.ts @@ -1,4 +1,5 @@ import type { AuthProvider } from '#root/auth/auth.provider.ts'; +import { Session } from './sessions.session.ts'; class SessionProvider { #handlers: Map; @@ -7,6 +8,10 @@ class SessionProvider { this.#handlers = new Map(); } + public get providers() { + return Array.from(this.#handlers.keys()); + } + public register = (name: string, provider: AuthProvider) => { this.#handlers.set(name, provider); }; @@ -18,6 +23,15 @@ class SessionProvider { } return handler.getAccess(token); }; + + public get = async (provider: string, token: string) => { + const handler = this.#handlers.get(provider); + if (!handler) { + throw new Error('Provider not available'); + } + const access = await handler.getAccess(token); + return new Session(access); + }; } export { SessionProvider };