diff --git a/bastion/pnpm-lock.yaml b/bastion/pnpm-lock.yaml index facd1ac..97eb4a0 100644 --- a/bastion/pnpm-lock.yaml +++ b/bastion/pnpm-lock.yaml @@ -13,16 +13,16 @@ importers: version: 22.19.15 '@typescript-eslint/eslint-plugin': specifier: ^8.57.1 - version: 8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3)(typescript@5.9.3))(eslint@10.0.3)(typescript@5.9.3) + version: 8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': specifier: ^8.57.1 - version: 8.57.1(eslint@10.0.3)(typescript@5.9.3) + version: 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) eslint: specifier: ^10.0.3 - version: 10.0.3 + version: 10.0.3(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.0.3) + version: 10.1.8(eslint@10.0.3(jiti@2.6.1)) rimraf: specifier: ^6.0.0 version: 6.1.3 @@ -34,7 +34,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/node@22.19.15)(tsx@4.21.0) + version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0) src/bastion: dependencies: @@ -74,6 +74,40 @@ importers: specifier: ^22.10.0 version: 22.19.15 + src/labd: + dependencies: + '@fastify/websocket': + specifier: ^11.0.2 + version: 11.2.0 + '@lab/shared': + specifier: workspace:* + version: link:../shared + '@prisma/client': + specifier: ^6.9.0 + version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) + fastify: + specifier: ^5.3.3 + version: 5.8.2 + winston: + specifier: ^3.17.0 + version: 3.19.0 + devDependencies: + '@types/node': + specifier: ^22.14.1 + version: 22.19.15 + prisma: + specifier: ^6.9.0 + version: 6.19.2(typescript@5.9.3) + rimraf: + specifier: ^6.1.3 + version: 6.1.3 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + src/shared: {} packages: @@ -298,6 +332,9 @@ packages: '@fastify/static@8.3.0': resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==} + '@fastify/websocket@11.2.0': + resolution: {integrity: sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -328,6 +365,36 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@prisma/client@6.19.2': + resolution: {integrity: sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@6.19.2': + resolution: {integrity: sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==} + + '@prisma/debug@6.19.2': + resolution: {integrity: sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': + resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + + '@prisma/engines@6.19.2': + resolution: {integrity: sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==} + + '@prisma/fetch-engine@6.19.2': + resolution: {integrity: sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==} + + '@prisma/get-platform@6.19.2': + resolution: {integrity: sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==} + '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] @@ -463,6 +530,9 @@ packages: '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -621,6 +691,14 @@ packages: resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -633,6 +711,16 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.1: + resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + color-convert@3.1.3: resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} engines: {node: '>=14.6'} @@ -653,6 +741,13 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -681,6 +776,13 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -689,9 +791,29 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + + effect@3.18.4: + resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + enabled@2.0.0: resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -766,6 +888,13 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -850,6 +979,10 @@ packages: get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -922,6 +1055,10 @@ packages: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} @@ -995,14 +1132,28 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + npm-run-path@6.0.0: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} + nypm@0.6.5: + resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} + engines: {node: '>=18'} + hasBin: true + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + one-time@1.0.0: resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} @@ -1048,6 +1199,9 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1065,6 +1219,9 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -1077,6 +1234,16 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} + prisma@6.19.2: + resolution: {integrity: sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + process-warning@4.0.1: resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} @@ -1087,13 +1254,23 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -1190,6 +1367,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -1213,6 +1393,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -1369,6 +1553,21 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1465,9 +1664,9 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.0.3)': + '@eslint-community/eslint-utils@4.9.1(eslint@10.0.3(jiti@2.6.1))': dependencies: - eslint: 10.0.3 + eslint: 10.0.3(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -1537,6 +1736,15 @@ snapshots: fastq: 1.20.1 glob: 11.1.0 + '@fastify/websocket@11.2.0': + dependencies: + duplexify: 4.1.3 + fastify-plugin: 5.1.0 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -1556,6 +1764,41 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)': + optionalDependencies: + prisma: 6.19.2(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@6.19.2': + dependencies: + c12: 3.1.0 + deepmerge-ts: 7.1.5 + effect: 3.18.4 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.19.2': {} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + + '@prisma/engines@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/fetch-engine': 6.19.2 + '@prisma/get-platform': 6.19.2 + + '@prisma/fetch-engine@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/get-platform': 6.19.2 + + '@prisma/get-platform@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -1640,6 +1883,8 @@ snapshots: color: 5.0.3 text-hex: 1.0.0 + '@standard-schema/spec@1.1.0': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1659,15 +1904,15 @@ snapshots: '@types/triple-beam@1.3.5': {} - '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3)(typescript@5.9.3))(eslint@10.0.3)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.57.1(eslint@10.0.3)(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.57.1 - '@typescript-eslint/type-utils': 8.57.1(eslint@10.0.3)(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.1(eslint@10.0.3)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.57.1 - eslint: 10.0.3 + eslint: 10.0.3(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -1675,14 +1920,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.57.1(eslint@10.0.3)(typescript@5.9.3)': + '@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.57.1 '@typescript-eslint/types': 8.57.1 '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.57.1 debug: 4.4.3 - eslint: 10.0.3 + eslint: 10.0.3(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -1705,13 +1950,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.57.1(eslint@10.0.3)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.57.1 '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.1(eslint@10.0.3)(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 10.0.3 + eslint: 10.0.3(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -1734,13 +1979,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.57.1(eslint@10.0.3)(typescript@5.9.3)': + '@typescript-eslint/utils@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.57.1 '@typescript-eslint/types': 8.57.1 '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) - eslint: 10.0.3 + eslint: 10.0.3(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -1758,13 +2003,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15)(tsx@4.21.0))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@22.19.15)(tsx@4.21.0) + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -1835,6 +2080,21 @@ snapshots: dependencies: balanced-match: 4.0.4 + c12@3.1.0: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.4 + dotenv: 16.6.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + cac@6.7.14: {} chai@5.3.3: @@ -1847,6 +2107,16 @@ snapshots: check-error@2.1.3: {} + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.1: {} + color-convert@3.1.3: dependencies: color-name: 2.1.0 @@ -1864,6 +2134,10 @@ snapshots: commander@13.1.0: {} + confbox@0.2.4: {} + + consola@3.4.2: {} + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -1884,12 +2158,38 @@ snapshots: deep-is@0.1.4: {} + deepmerge-ts@7.1.5: {} + + defu@6.1.4: {} + depd@2.0.0: {} dequal@2.0.3: {} + destr@2.0.5: {} + + dotenv@16.6.1: {} + + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + + effect@3.18.4: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + empathic@2.0.0: {} + enabled@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + es-module-lexer@1.7.0: {} esbuild@0.27.4: @@ -1925,9 +2225,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.0.3): + eslint-config-prettier@10.1.8(eslint@10.0.3(jiti@2.6.1)): dependencies: - eslint: 10.0.3 + eslint: 10.0.3(jiti@2.6.1) eslint-scope@9.1.2: dependencies: @@ -1940,9 +2240,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.0.3: + eslint@10.0.3(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.3 '@eslint/config-helpers': 0.5.3 @@ -1972,6 +2272,8 @@ snapshots: minimatch: 10.2.4 natural-compare: 1.4.0 optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 transitivePeerDependencies: - supports-color @@ -2014,6 +2316,12 @@ snapshots: expect-type@1.3.0: {} + exsolve@1.0.8: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -2112,6 +2420,15 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.5 + pathe: 2.0.3 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -2171,6 +2488,8 @@ snapshots: dependencies: '@isaacs/cliui': 9.0.0 + jiti@2.6.1: {} + js-tokens@9.0.1: {} json-buffer@3.0.1: {} @@ -2237,13 +2556,27 @@ snapshots: natural-compare@1.4.0: {} + node-fetch-native@1.6.7: {} + npm-run-path@6.0.0: dependencies: path-key: 4.0.0 unicorn-magic: 0.3.0 + nypm@0.6.5: + dependencies: + citty: 0.2.1 + pathe: 2.0.3 + tinyexec: 1.0.4 + + ohash@2.0.11: {} + on-exit-leak-free@2.1.2: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + one-time@1.0.0: dependencies: fn.name: 1.1.0 @@ -2284,6 +2617,8 @@ snapshots: pathval@2.0.1: {} + perfect-debounce@1.0.0: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -2308,6 +2643,12 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.0.0 + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -2320,20 +2661,38 @@ snapshots: dependencies: parse-ms: 4.0.0 + prisma@6.19.2(typescript@5.9.3): + dependencies: + '@prisma/config': 6.19.2 + '@prisma/engines': 6.19.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + process-warning@4.0.1: {} process-warning@5.0.0: {} punycode@2.3.1: {} + pure-rand@6.1.0: {} + quick-format-unescaped@4.0.4: {} + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + readdirp@4.1.2: {} + real-require@0.2.0: {} require-from-string@2.0.2: {} @@ -2424,6 +2783,8 @@ snapshots: std-env@3.10.0: {} + stream-shift@1.0.3: {} + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -2444,6 +2805,8 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.0.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -2488,13 +2851,13 @@ snapshots: util-deprecate@1.0.2: {} - vite-node@3.2.4(@types/node@22.19.15)(tsx@4.21.0): + vite-node@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@22.19.15)(tsx@4.21.0) + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0) transitivePeerDependencies: - '@types/node' - jiti @@ -2509,7 +2872,7 @@ snapshots: - tsx - yaml - vite@7.3.1(@types/node@22.19.15)(tsx@4.21.0): + vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.3) @@ -2520,13 +2883,14 @@ snapshots: optionalDependencies: '@types/node': 22.19.15 fsevents: 2.3.3 + jiti: 2.6.1 tsx: 4.21.0 - vitest@3.2.4(@types/node@22.19.15)(tsx@4.21.0): + vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)(tsx@4.21.0)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -2544,8 +2908,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@22.19.15)(tsx@4.21.0) - vite-node: 3.2.4(@types/node@22.19.15)(tsx@4.21.0) + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.15 @@ -2594,6 +2958,10 @@ snapshots: word-wrap@1.2.5: {} + wrappy@1.0.2: {} + + ws@8.19.0: {} + yocto-queue@0.1.0: {} yoctocolors@2.1.2: {} diff --git a/bastion/src/labd/package.json b/bastion/src/labd/package.json new file mode 100644 index 0000000..3f20977 --- /dev/null +++ b/bastion/src/labd/package.json @@ -0,0 +1,36 @@ +{ + "name": "@lab/labd", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/main.js", + "types": "./dist/main.d.ts", + "exports": { + ".": { + "import": "./dist/main.js", + "types": "./dist/main.d.ts" + } + }, + "scripts": { + "build": "tsc --build", + "clean": "rimraf dist", + "dev": "tsx src/main.ts", + "db:push": "prisma db push", + "db:migrate": "prisma migrate dev", + "db:generate": "prisma generate" + }, + "dependencies": { + "@lab/shared": "workspace:*", + "@prisma/client": "^6.9.0", + "fastify": "^5.3.3", + "@fastify/websocket": "^11.0.2", + "winston": "^3.17.0" + }, + "devDependencies": { + "@types/node": "^22.14.1", + "prisma": "^6.9.0", + "rimraf": "^6.1.3", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/bastion/src/labd/prisma/schema.prisma b/bastion/src/labd/prisma/schema.prisma new file mode 100644 index 0000000..4d826ac --- /dev/null +++ b/bastion/src/labd/prisma/schema.prisma @@ -0,0 +1,145 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "cockroachdb" + url = env("DATABASE_URL") +} + +model Server { + id String @id @default(uuid()) + hostname String @unique + mac String? @unique + cloud String @default("baremetal") + environment String @default("default") + role String @default("worker") + labels Json @default("{}") + ip String? + agentVersion String? + status String @default("unknown") // unknown, online, offline, provisioning + lastHeartbeat DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + agent Agent? + auditLogs AuditLog[] +} + +model Agent { + id String @id @default(uuid()) + serverId String @unique + server Server @relation(fields: [serverId], references: [id], onDelete: Cascade) + certificatePem String? + enrolledAt DateTime @default(now()) + lastSeen DateTime? + + @@index([serverId]) +} + +model User { + id String @id @default(uuid()) + username String @unique + displayName String? + certFingerprint String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + roleBindings UserRole[] + auditLogs AuditLog[] +} + +model Role { + id String @id @default(uuid()) + name String @unique + description String? + createdAt DateTime @default(now()) + + permissions Permission[] + userBindings UserRole[] +} + +model Permission { + id String @id @default(uuid()) + roleId String + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + type String @default("allow") // allow or deny + action String // read, exec, apply, destroy, manage, admin, kubectl, * + cloud String @default("*") + environment String @default("*") + server String @default("*") + + @@index([roleId]) +} + +model UserRole { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + roleId String + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + + @@unique([userId, roleId]) + @@index([userId]) + @@index([roleId]) +} + +model JoinToken { + id String @id @default(uuid()) + token String @unique + type String @default("one-time") // one-time or reusable + label String? + usedBy String? // server hostname that used it + usedAt DateTime? + revokedAt DateTime? + createdAt DateTime @default(now()) + expiresAt DateTime? +} + +model AuditLog { + id String @id @default(uuid()) + userId String? + user User? @relation(fields: [userId], references: [id]) + serverId String? + server Server? @relation(fields: [serverId], references: [id]) + sessionId String? + action String // exec, kubectl, apply, login, rbac-denied, etc. + resourceType String? // server, cluster, role, app, etc. + resourceName String? + args String? // sanitized command args + result String @default("success") // success, denied, error + durationMs Int? + sourceIp String? + timestamp DateTime @default(now()) + + @@index([userId]) + @@index([serverId]) + @@index([sessionId]) + @@index([timestamp]) + @@index([action]) +} + +model PulumiRun { + id String @id @default(uuid()) + userId String + stackName String + action String // up, preview, destroy + status String @default("pending") // pending, running, succeeded, failed + output String? + startedAt DateTime @default(now()) + completedAt DateTime? + + @@index([userId]) + @@index([stackName]) +} + +model Cluster { + id String @id @default(uuid()) + name String @unique + cloud String @default("baremetal") + environment String @default("default") + kubeconfigEnc String? // encrypted kubeconfig + labels Json @default("{}") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/bastion/src/labd/src/config.ts b/bastion/src/labd/src/config.ts new file mode 100644 index 0000000..0414f9c --- /dev/null +++ b/bastion/src/labd/src/config.ts @@ -0,0 +1,19 @@ +// Configuration from environment variables with sensible defaults. + +export interface LabdConfig { + port: number; + host: string; + databaseUrl: string; + caDir: string; + logLevel: string; +} + +export function loadConfig(overrides: Partial = {}): LabdConfig { + return { + port: overrides.port ?? parseInt(process.env["LABD_PORT"] ?? "3100", 10), + host: overrides.host ?? process.env["LABD_HOST"] ?? "0.0.0.0", + databaseUrl: overrides.databaseUrl ?? process.env["DATABASE_URL"] ?? "", + caDir: overrides.caDir ?? process.env["CA_DIR"] ?? "/etc/labd/ca", + logLevel: overrides.logLevel ?? process.env["LABD_LOG_LEVEL"] ?? "info", + }; +} diff --git a/bastion/src/labd/src/main.ts b/bastion/src/labd/src/main.ts new file mode 100644 index 0000000..a367e69 --- /dev/null +++ b/bastion/src/labd/src/main.ts @@ -0,0 +1,91 @@ +// Entry point for the lab master daemon (labd). +// Initializes Prisma, starts Fastify with WebSocket support, registers routes. + +import { loadConfig } from "./config.js"; +import { createApp } from "./server.js"; +import { logger } from "./services/logger.js"; + +async function main(): Promise { + const config = loadConfig(); + + // Initialize Prisma client (wrapped in try/catch for when DB isn't available) + let db; + try { + const { PrismaClient } = await import("@prisma/client"); + const prisma = new PrismaClient({ + datasources: config.databaseUrl + ? { db: { url: config.databaseUrl } } + : undefined, + }); + await prisma.$connect(); + logger.info("Database connected"); + db = prisma; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.warn(`Database not available: ${message}`); + logger.warn("Running without database -- some features will be unavailable"); + + // Create a stub db client that returns errors for all operations + db = { + $queryRaw: async () => { + throw new Error("Database not connected"); + }, + server: { + findMany: async () => { + throw new Error("Database not connected"); + }, + findUnique: async () => { + throw new Error("Database not connected"); + }, + }, + joinToken: { + findUnique: async () => { + throw new Error("Database not connected"); + }, + findMany: async () => { + throw new Error("Database not connected"); + }, + create: async () => { + throw new Error("Database not connected"); + }, + update: async () => { + throw new Error("Database not connected"); + }, + }, + }; + } + + // Create Fastify app + const { app } = createApp(config, db); + + // Start server + try { + await app.listen({ port: config.port, host: config.host }); + logger.info(`labd listening on ${config.host}:${config.port}`); + } catch (err) { + logger.error(`Failed to start server: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + + // Graceful shutdown + const shutdown = async (): Promise => { + logger.info("Shutting down..."); + await app.close(); + if (db !== null && "$disconnect" in db) { + await (db as { $disconnect: () => Promise }).$disconnect(); + } + logger.info("Goodbye"); + process.exit(0); + }; + + process.on("SIGINT", () => void shutdown()); + process.on("SIGTERM", () => void shutdown()); + + // Keep process alive + await new Promise(() => {}); +} + +main().catch((err) => { + console.error("Failed to start labd:", err); + process.exit(1); +}); diff --git a/bastion/src/labd/src/middleware/auth.ts b/bastion/src/labd/src/middleware/auth.ts new file mode 100644 index 0000000..5da371c --- /dev/null +++ b/bastion/src/labd/src/middleware/auth.ts @@ -0,0 +1,33 @@ +// Placeholder mTLS auth middleware. +// Extracts client certificate info from the request and resolves user/agent identity. + +import type { FastifyRequest, FastifyReply } from "fastify"; +import { logger } from "../services/logger.js"; + +declare module "fastify" { + interface FastifyRequest { + clientCertFingerprint?: string; + authenticatedUser?: string; + authenticatedAgent?: string; + } +} + +export function createMtlsAuthMiddleware(): ( + request: FastifyRequest, + reply: FastifyReply, +) => Promise { + return async function mtlsAuthMiddleware( + request: FastifyRequest, + _reply: FastifyReply, + ): Promise { + // TODO: Extract client certificate from TLS connection + // const cert = (request.raw.socket as TLSSocket).getPeerCertificate(); + // For now, this is a no-op placeholder + + const certHeader = request.headers["x-client-cert-fingerprint"]; + if (typeof certHeader === "string" && certHeader.length > 0) { + request.clientCertFingerprint = certHeader; + logger.info(`mTLS: client cert fingerprint=${certHeader.slice(0, 16)}...`); + } + }; +} diff --git a/bastion/src/labd/src/routes/auth.ts b/bastion/src/labd/src/routes/auth.ts new file mode 100644 index 0000000..7c1c1eb --- /dev/null +++ b/bastion/src/labd/src/routes/auth.ts @@ -0,0 +1,163 @@ +// Authentication and token management routes. +// POST /api/auth/enroll — agent enrollment (token + CSR -> signed cert) +// POST /api/tokens — create join token +// GET /api/tokens — list tokens +// DELETE /api/tokens/:id — revoke token + +import { randomBytes } from "node:crypto"; +import type { FastifyInstance } from "fastify"; +import type { DbClient } from "../server.js"; +import { logger } from "../services/logger.js"; + +export function registerAuthRoutes(app: FastifyInstance, db: DbClient): void { + // Agent enrollment: validate join token, accept CSR, return signed cert + app.post<{ + Body: { + token?: string; + hostname?: string; + csr?: string; + }; + }>("/api/auth/enroll", async (request, reply) => { + const { token, hostname, csr } = request.body ?? {}; + + if (token === undefined || token === "") { + return reply.code(400).send({ error: "token is required" }); + } + if (hostname === undefined || hostname === "") { + return reply.code(400).send({ error: "hostname is required" }); + } + + try { + // Validate token + const joinToken = await db.joinToken.findUnique({ + where: { token }, + }) as { id: string; type: string; usedBy: string | null; revokedAt: Date | null; expiresAt: Date | null } | null; + + if (joinToken === null) { + return reply.code(401).send({ error: "Invalid join token" }); + } + if (joinToken.revokedAt !== null) { + return reply.code(401).send({ error: "Token has been revoked" }); + } + if (joinToken.expiresAt !== null && joinToken.expiresAt < new Date()) { + return reply.code(401).send({ error: "Token has expired" }); + } + if (joinToken.type === "one-time" && joinToken.usedBy !== null) { + return reply.code(401).send({ error: "Token has already been used" }); + } + + // Mark token as used + await db.joinToken.update({ + where: { id: joinToken.id }, + data: { + usedBy: hostname, + usedAt: new Date(), + }, + }); + + logger.info(`AGENT ENROLLED: ${hostname} (token=${joinToken.id.slice(0, 8)}...)`); + + // TODO: Sign CSR with CA and return certificate + // For now, return a placeholder acknowledging enrollment + return reply.send({ + status: "enrolled", + hostname, + message: "Agent enrolled successfully", + certificatePem: null, // TODO: implement CA signing + csr: csr !== undefined ? "received" : "not provided", + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return reply.code(500).send({ error: "Enrollment failed", detail: message }); + } + }); + + // Create a new join token + app.post<{ + Body: { + type?: string; + label?: string; + expiresInHours?: number; + }; + }>("/api/tokens", async (request, reply) => { + const { type, label, expiresInHours } = request.body ?? {}; + + const tokenType = type ?? "one-time"; + if (tokenType !== "one-time" && tokenType !== "reusable") { + return reply.code(400).send({ error: "type must be 'one-time' or 'reusable'" }); + } + + const tokenValue = randomBytes(32).toString("hex"); + const expiresAt = expiresInHours !== undefined + ? new Date(Date.now() + expiresInHours * 60 * 60 * 1000) + : undefined; + + try { + const created = await db.joinToken.create({ + data: { + token: tokenValue, + type: tokenType, + label: label ?? null, + expiresAt: expiresAt ?? null, + }, + }); + + logger.info(`TOKEN CREATED: ${(created as { id: string }).id} type=${tokenType} label=${label ?? "(none)"}`); + + return reply.code(201).send({ + id: (created as { id: string }).id, + token: tokenValue, + type: tokenType, + label: label ?? null, + expiresAt: expiresAt?.toISOString() ?? null, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return reply.code(500).send({ error: "Failed to create token", detail: message }); + } + }); + + // List tokens + app.get("/api/tokens", async (_request, reply) => { + try { + const tokens = await db.joinToken.findMany({ + orderBy: { createdAt: "desc" }, + select: { + id: true, + type: true, + label: true, + usedBy: true, + usedAt: true, + revokedAt: true, + createdAt: true, + expiresAt: true, + // Intentionally omit token value for security + }, + }); + return reply.send(tokens); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return reply.code(500).send({ error: "Failed to list tokens", detail: message }); + } + }); + + // Revoke a token + app.delete<{ + Params: { id: string }; + }>("/api/tokens/:id", async (request, reply) => { + const { id } = request.params; + + try { + await db.joinToken.update({ + where: { id }, + data: { revokedAt: new Date() }, + }); + + logger.info(`TOKEN REVOKED: ${id}`); + return reply.send({ status: "revoked", id }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return reply.code(500).send({ error: "Failed to revoke token", detail: message }); + } + }); +} diff --git a/bastion/src/labd/src/routes/health.ts b/bastion/src/labd/src/routes/health.ts new file mode 100644 index 0000000..b1b7c08 --- /dev/null +++ b/bastion/src/labd/src/routes/health.ts @@ -0,0 +1,28 @@ +// Health check routes. + +import type { FastifyInstance } from "fastify"; +import type { DbClient } from "../server.js"; + +export function registerHealthRoutes(app: FastifyInstance, db: DbClient): void { + app.get("/healthz", async (_request, reply) => { + let dbOk = false; + try { + await db.$queryRaw`SELECT 1`; + dbOk = true; + } catch { + // DB not reachable + } + + const status = dbOk ? "healthy" : "degraded"; + const statusCode = dbOk ? 200 : 503; + + return reply.code(statusCode).send({ + status, + uptime: process.uptime(), + timestamp: new Date().toISOString(), + checks: { + database: dbOk ? "ok" : "error", + }, + }); + }); +} diff --git a/bastion/src/labd/src/routes/servers.ts b/bastion/src/labd/src/routes/servers.ts new file mode 100644 index 0000000..50ad4ed --- /dev/null +++ b/bastion/src/labd/src/routes/servers.ts @@ -0,0 +1,64 @@ +// Server management routes. +// GET /api/servers — list servers with optional filters (cloud, environment, label) +// GET /api/servers/:id — get server details + +import type { FastifyInstance } from "fastify"; +import type { DbClient } from "../server.js"; + +export function registerServerRoutes(app: FastifyInstance, db: DbClient): void { + // List servers with optional filters + app.get<{ + Querystring: { + cloud?: string; + environment?: string; + status?: string; + }; + }>("/api/servers", async (request, reply) => { + const { cloud, environment, status } = request.query; + + const where: Record = {}; + if (cloud !== undefined && cloud !== "") { + where["cloud"] = cloud; + } + if (environment !== undefined && environment !== "") { + where["environment"] = environment; + } + if (status !== undefined && status !== "") { + where["status"] = status; + } + + try { + const servers = await db.server.findMany({ + where: Object.keys(where).length > 0 ? where : undefined, + orderBy: { hostname: "asc" }, + }); + return reply.send(servers); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return reply.code(500).send({ error: "Failed to list servers", detail: message }); + } + }); + + // Get server details by ID + app.get<{ + Params: { id: string }; + }>("/api/servers/:id", async (request, reply) => { + const { id } = request.params; + + try { + const server = await db.server.findUnique({ + where: { id }, + include: { agent: true }, + }); + + if (server === null) { + return reply.code(404).send({ error: "Server not found", id }); + } + + return reply.send(server); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return reply.code(500).send({ error: "Failed to get server", detail: message }); + } + }); +} diff --git a/bastion/src/labd/src/server.ts b/bastion/src/labd/src/server.ts new file mode 100644 index 0000000..848e440 --- /dev/null +++ b/bastion/src/labd/src/server.ts @@ -0,0 +1,63 @@ +// Fastify application setup with all routes registered. + +import Fastify from "fastify"; +import websocket from "@fastify/websocket"; +import type { LabdConfig } from "./config.js"; +import { logger } from "./services/logger.js"; +import { registerHealthRoutes } from "./routes/health.js"; +import { registerServerRoutes } from "./routes/servers.js"; +import { registerAuthRoutes } from "./routes/auth.js"; + +export interface DbClient { + $queryRaw: (query: TemplateStringsArray) => Promise; + server: { + findMany: (args?: unknown) => Promise; + findUnique: (args: unknown) => Promise; + }; + joinToken: { + findUnique: (args: unknown) => Promise; + findMany: (args?: unknown) => Promise; + create: (args: unknown) => Promise; + update: (args: unknown) => Promise; + }; +} + +export function createApp(_config: LabdConfig, db: DbClient): { + app: ReturnType; +} { + const app = Fastify({ + logger: false, // We use winston instead + }); + + // Register WebSocket support + void app.register(websocket); + + // Register route handlers + registerHealthRoutes(app, db); + registerServerRoutes(app, db); + registerAuthRoutes(app, db); + + // WebSocket handler for agent connections + app.register(async (fastify) => { + fastify.get("/ws/agent", { websocket: true }, (socket, _request) => { + logger.info("Agent WebSocket connection established"); + + socket.on("message", (message: Buffer) => { + const data = message.toString(); + logger.info(`Agent message: ${data}`); + // TODO: Handle agent heartbeat, command relay, etc. + }); + + socket.on("close", () => { + logger.info("Agent WebSocket connection closed"); + }); + }); + }); + + // Log all requests + app.addHook("onRequest", async (request) => { + logger.info(`HTTP: ${request.ip} ${request.method} ${request.url}`); + }); + + return { app }; +} diff --git a/bastion/src/labd/src/services/logger.ts b/bastion/src/labd/src/services/logger.ts new file mode 100644 index 0000000..b3178c7 --- /dev/null +++ b/bastion/src/labd/src/services/logger.ts @@ -0,0 +1,17 @@ +// Winston logger instance shared across the labd application. + +import winston from "winston"; + +export const logger = winston.createLogger({ + level: process.env["LABD_LOG_LEVEL"] ?? "info", + format: winston.format.combine( + winston.format.timestamp({ format: "HH:mm:ss" }), + winston.format.printf(({ timestamp, level, message }) => { + const prefix = level === "error" ? "\x1b[31m[labd]\x1b[0m" + : level === "warn" ? "\x1b[33m[labd]\x1b[0m" + : "\x1b[36m[labd]\x1b[0m"; + return `${prefix} ${timestamp as string} ${message as string}`; + }), + ), + transports: [new winston.transports.Console()], +}); diff --git a/bastion/src/labd/tests/health.test.ts b/bastion/src/labd/tests/health.test.ts new file mode 100644 index 0000000..6d3bc80 --- /dev/null +++ b/bastion/src/labd/tests/health.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from "vitest"; +import Fastify from "fastify"; +import { registerHealthRoutes } from "../src/routes/health.js"; +import type { DbClient } from "../src/server.js"; + +function createMockDb(overrides: Partial = {}): DbClient { + return { + $queryRaw: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + server: { + findMany: vi.fn().mockResolvedValue([]), + findUnique: vi.fn().mockResolvedValue(null), + }, + joinToken: { + findUnique: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue({ id: "test-id" }), + update: vi.fn().mockResolvedValue({}), + }, + ...overrides, + }; +} + +describe("Health endpoint", () => { + it("returns healthy when database is reachable", async () => { + const app = Fastify({ logger: false }); + const db = createMockDb(); + + registerHealthRoutes(app, db); + + const response = await app.inject({ + method: "GET", + url: "/healthz", + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.status).toBe("healthy"); + expect(body.checks.database).toBe("ok"); + expect(body.uptime).toBeTypeOf("number"); + expect(body.timestamp).toBeTypeOf("string"); + + await app.close(); + }); + + it("returns degraded when database is unreachable", async () => { + const app = Fastify({ logger: false }); + const db = createMockDb({ + $queryRaw: vi.fn().mockRejectedValue(new Error("Connection refused")), + }); + + registerHealthRoutes(app, db); + + const response = await app.inject({ + method: "GET", + url: "/healthz", + }); + + expect(response.statusCode).toBe(503); + const body = JSON.parse(response.body); + expect(body.status).toBe("degraded"); + expect(body.checks.database).toBe("error"); + + await app.close(); + }); +}); diff --git a/bastion/src/labd/tsconfig.json b/bastion/src/labd/tsconfig.json new file mode 100644 index 0000000..4c4fbfc --- /dev/null +++ b/bastion/src/labd/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../shared" } + ] +} diff --git a/bastion/src/labd/vitest.config.ts b/bastion/src/labd/vitest.config.ts new file mode 100644 index 0000000..fc23bdd --- /dev/null +++ b/bastion/src/labd/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + name: 'labd', + include: ['tests/**/*.test.ts'], + }, +}); diff --git a/bastion/stack/.env.example b/bastion/stack/.env.example index c968d21..6f27615 100644 --- a/bastion/stack/.env.example +++ b/bastion/stack/.env.example @@ -31,3 +31,6 @@ DHCP_RANGE_END= # Path to SSH keys directory on host (mounted read-only) SSH_KEY_PATH=~/.ssh + +# CockroachDB connection (used by labd) +DATABASE_URL=postgresql://root@localhost:26257/labctl?sslmode=disable diff --git a/bastion/stack/docker-compose.yml b/bastion/stack/docker-compose.yml index ce07372..e148b68 100644 --- a/bastion/stack/docker-compose.yml +++ b/bastion/stack/docker-compose.yml @@ -15,6 +15,18 @@ services: - NET_ADMIN - NET_RAW + cockroachdb: + image: cockroachdb/cockroach:latest-v24.3 + command: start-single-node --insecure --store=type=mem,size=256MiB + ports: + - "26257:26257" + - "8081:8080" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health?ready=1"] + interval: 5s + timeout: 5s + retries: 10 + volumes: bastion-state: bastion-tftp: diff --git a/bastion/tsconfig.json b/bastion/tsconfig.json index a45b276..c353bd2 100644 --- a/bastion/tsconfig.json +++ b/bastion/tsconfig.json @@ -3,6 +3,7 @@ "references": [ { "path": "src/shared" }, { "path": "src/bastion" }, - { "path": "src/cli" } + { "path": "src/cli" }, + { "path": "src/labd" } ] }