diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 249e064..4f0a546 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -36,6 +36,24 @@ Common mistakes to avoid: - `ts/classes/` - All class implementations - `ts/` - Root level utilities (logging, types, plugins, cli, info) +### WebSocket Real-time Communication +- **Backend**: WebSocket endpoint at `/api/ws` (`ts/classes/httpserver.ts:96-174`) + - Connection management with client Set tracking + - Broadcast methods: `broadcast()`, `broadcastServiceUpdate()`, `broadcastServiceStatus()` + - Integrated with service lifecycle (start/stop/restart actions) + - Status monitoring loop broadcasts changes automatically +- **Frontend**: Angular WebSocket service (`ui/src/app/core/services/websocket.service.ts`) + - Auto-connects on app initialization + - Exponential backoff reconnection (max 5 attempts) + - RxJS Observable-based message streaming + - Components subscribe to real-time updates +- **Message Types**: + - `connected` - Initial connection confirmation + - `service_update` - Service lifecycle changes (action: created/updated/deleted/started/stopped) + - `service_status` - Real-time status changes from monitoring loop + - `system_status` - System-wide updates +- **Testing**: Use `.nogit/test-ws-updates.ts` to monitor WebSocket messages + ### Docker Configuration - **System Docker**: Uses root Docker at `/var/run/docker.sock` (NOT rootless) - **Swarm Mode**: Enabled for service orchestration diff --git a/deno.json b/deno.json index 9ecb7af..2ede29c 100644 --- a/deno.json +++ b/deno.json @@ -17,7 +17,7 @@ "@std/encoding": "jsr:@std/encoding@^1.0.10", "@db/sqlite": "jsr:@db/sqlite@0.12.0", "@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.1.0", - "@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@2.0.0", + "@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@2.1.0", "@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3", "@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 48a2d20..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,1242 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@apiclient.xyz/cloudflare': - specifier: 6.4.3 - version: 6.4.3 - -packages: - - '@api.global/typedrequest-interfaces@3.0.19': - resolution: {integrity: sha512-uuHUXJeOy/inWSDrwD0Cwax2rovpxYllDhM2RWh+6mVpQuNmZ3uw6IVg6dA2G1rOe24Ebs+Y9SzEogo+jYN7vw==} - - '@apiclient.xyz/cloudflare@6.4.3': - resolution: {integrity: sha512-ztegUdUO3Zd4mUoTSylKlCEKPBMHEcggrLelR+7CiblM4beHMwopMVlryBmiCY7bOVbUSPoK0xsVTF7VIy3p/A==} - - '@borewit/text-codec@0.1.1': - resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==} - - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.0': - resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} - engines: {node: 20 || >=22} - - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - - '@push.rocks/consolecolor@2.0.3': - resolution: {integrity: sha512-hA+m0BMqEwZNSAS7c2aQFfoPkpX/dNdsHzkdLdeERUOy7BLacb9ItTUofGtjtginP0yDj4NSpqSjNYyX3Y8Y/w==} - - '@push.rocks/isounique@1.0.5': - resolution: {integrity: sha512-Z0BVqZZOCif1THTbIKWMgg0wxCzt9CyBtBBqQJiZ+jJ0KlQFrQHNHrPt81/LXe/L4x0cxWsn0bpL6W5DNSvNLw==} - - '@push.rocks/lik@6.2.2': - resolution: {integrity: sha512-j64FFPPyMXeeUorjKJVF6PWaJUfiIrF3pc41iJH4lOh0UUpBAHpcNzHVxTR58orwbVA/h3Hz+DQd4b1Rq0dFDQ==} - - '@push.rocks/smartclickhouse@2.0.17': - resolution: {integrity: sha512-IYO8Obor/Ruam2KQ2B/+5uQ+rL0exU5KZoSgOc3jkkrfjn+zZenN2xoV8lVqavAtxZVfG7MfxFrcv6I7I9ZMmA==} - - '@push.rocks/smartdelay@3.0.5': - resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==} - - '@push.rocks/smartenv@5.0.13': - resolution: {integrity: sha512-ACXmUcHZHl2CF2jnVuRw9saRRrZvJblCRs2d+K5aLR1DfkYFX3eA21kcMlKeLisI3aGNbIj9vz/rowN5qkRkfA==} - - '@push.rocks/smartenv@6.0.0': - resolution: {integrity: sha512-ktW5MqOFs0492sB4vrvl4lgRFQ/sQ4AyREgB+sCIzGqszHWGVvGXR95Y2a3z66jkLPYML2CUWHzmMlfv8fkG+A==} - - '@push.rocks/smartfile-interfaces@1.0.7': - resolution: {integrity: sha512-MeOl/200UOvSO4Pgq/DVFiBVZpL9gjOBQM+4XYNjSxda8c6VBvchHAntaFLQUlO8U1ckNaP9i+nMO4O4/0ymyw==} - - '@push.rocks/smartfile@11.2.7': - resolution: {integrity: sha512-8Yp7/sAgPpWJBHohV92ogHWKzRomI5MEbSG6b5W2n18tqwfAmjMed0rQvsvGrSBlnEWCKgoOrYIIZbLO61+J0Q==} - - '@push.rocks/smarthash@3.2.6': - resolution: {integrity: sha512-Mq/WNX0Tjjes3X1gHd/ZBwOOKSrAG/Z3Xoc0OcCm3P20WKpniihkMpsnlE7wGjvpHLi/ZRe/XkB3KC3d5r9X4g==} - - '@push.rocks/smartjson@5.2.0': - resolution: {integrity: sha512-710e8UwovRfPgUtaBHcd6unaODUjV5fjxtGcGCqtaTcmvOV6VpasdVfT66xMDzQmWH2E9ZfHDJeso9HdDQzNQA==} - - '@push.rocks/smartlog@3.1.10': - resolution: {integrity: sha512-5pf5JyzOE2WTCUislNIW4EHePo1a7hiXB+jbil38+N5hW71AEwcPFe6oGxbp5w9ALlz66hV2+E+25R0SsxN+fQ==} - - '@push.rocks/smartmatch@2.0.0': - resolution: {integrity: sha512-MBzP++1yNIBeox71X6VxpIgZ8m4bXnJpZJ4nWVH6IWpmO38MXTu4X0QF8tQnyT4LFcwvc9iiWaD15cstHa7Mmw==} - - '@push.rocks/smartmime@2.0.4': - resolution: {integrity: sha512-mG6lRBLr5nF+GLZmgCcdjhdDsmTtJWBFZDCa1eJ8Au9TvUzbPW0fY5aqJBb3UwfyZzH6St8Th9cJSXjagOQkYA==} - - '@push.rocks/smartobject@1.0.12': - resolution: {integrity: sha512-xSMiqXiZXXUOixT3QIPsOUKOWjL3YA/1h9/YTiCzqs5C0D3tyfTbojnfcp6YbKZoBzans2I5LghaDHsGid2DKQ==} - - '@push.rocks/smartpath@6.0.0': - resolution: {integrity: sha512-r94u1MbBaIOSy+517PZp2P7SuZPSe9LkwJ8l3dXQKHeIOri/zDxk/RQPiFM+j4N9301ztkRyhvRj7xgUDroOsg==} - - '@push.rocks/smartpromise@4.2.3': - resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==} - - '@push.rocks/smartrequest@4.4.2': - resolution: {integrity: sha512-Om4y1Ce4YdSu8VoXREz2SgFz9pDxcFEm0+SC1YYa3RXd0AH2Mknaj/1XfvfMqojnK9L7N2z1fY4xX8tO1IwqFQ==} - - '@push.rocks/smartrequest@5.0.1': - resolution: {integrity: sha512-gZQQF6HVt3LwTBxaPh6hHObd4VF76PUYQcs5pHD7f0VXaEewmrNAQSnccoinOY7fi45+0dOf04PJOXu9MibPzQ==} - - '@push.rocks/smartrx@3.0.10': - resolution: {integrity: sha512-USjIYcsSfzn14cwOsxgq/bBmWDTTzy3ouWAnW5NdMyRRzEbmeNrvmy6TRqNeDlJ2PsYNTt1rr/zGUqvIy72ITg==} - - '@push.rocks/smartstream@3.2.5': - resolution: {integrity: sha512-PLGGIFDy8JLNVUnnntMSIYN4W081YSbNC7Y/sWpvUT8PAXtbEXXUiDFgK5o3gcI0ptpKQxHAwxhzNlPj0sbFVg==} - - '@push.rocks/smartstring@4.1.0': - resolution: {integrity: sha512-Q4py/Nm3KTDhQ9EiC75yBtSTLR0KLMwhKM+8gGcutgKotZT6wJ3gncjmtD8LKFfNhb4lSaFMgPJgLrCHTOH6Iw==} - - '@push.rocks/smarttime@4.1.1': - resolution: {integrity: sha512-Ha/3J/G+zfTl4ahpZgF6oUOZnUjpLhrBja0OQ2cloFxF9sKT8I1COaSqIfBGDtoK2Nly4UD4aTJ3JcJNOg/kgA==} - - '@push.rocks/smarturl@3.1.0': - resolution: {integrity: sha512-ij73Q4GERojdPSHxAvYKvspimcpAJC6GGQCWsC4b+1sAiOSByjfmkUHK8yiEEOPRU9AeGuyaIVqK6ZzKLEZ3vA==} - - '@push.rocks/webrequest@3.0.37': - resolution: {integrity: sha512-fLN7kP6GeHFxE4UH4r9C9pjcQb0QkJxHeAMwXvbOqB9hh0MFNKhtGU7GoaTn8SVRGRMPc9UqZVNwo6u5l8Wn0A==} - - '@push.rocks/webstore@2.0.20': - resolution: {integrity: sha512-Z3L4OHGcw/Gs9aXpMUwebEPTh0nK/C7R6YwPfCLcGVu9yd/ZShaQ8QZEYE243Cu9J1Mn+CEtz4jpPLnHiizHQA==} - - '@sec-ant/readable-stream@0.4.1': - resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - - '@tempfix/idb@8.0.3': - resolution: {integrity: sha512-hPJQKO7+oAIY+pDNImrZ9QAINbz9KmwT+yO4iRVwdPanok2YKpaUxdJzIvCUwY0YgAawlvYdffbLvRLV5hbs2g==} - - '@tokenizer/token@0.3.0': - resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - - '@tsclass/tsclass@9.3.0': - resolution: {integrity: sha512-KD3oTUN3RGu67tgjNHgWWZGsdYipr1RUDxQ9MMKSgIJ6oNZ4q5m2rg0ibrgyHWkAjTPlHVa6kHP3uVOY+8bnHw==} - - '@types/fs-extra@11.0.4': - resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} - - '@types/js-yaml@4.0.9': - resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - - '@types/jsonfile@6.1.4': - resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} - - '@types/mime-types@2.1.4': - resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} - - '@types/minimatch@5.1.2': - resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} - - '@types/node-fetch@2.6.13': - resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} - - '@types/node@18.19.130': - resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - - '@types/node@24.10.1': - resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} - - '@types/symbol-tree@3.2.5': - resolution: {integrity: sha512-zXnnyENt1TYQcS21MkPaJCVjfcPq7p7yc5mo5JACuumXp6sly5jnlS0IokHd+xmmuCbx6V7JqkMBpswR+nZAcw==} - - '@types/through2@2.0.41': - resolution: {integrity: sha512-ryQ0tidWkb1O1JuYvWKyMLYEtOWDqF5mHerJzKz/gQpoAaJq2l/dsMPBF0B5BNVT34rbARYJ5/tsZwLfUi2kwQ==} - - abort-controller@3.0.0: - resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} - engines: {node: '>=6.5'} - - agentkeepalive@4.6.0: - resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} - engines: {node: '>= 8.0.0'} - - ansi-256-colors@1.1.0: - resolution: {integrity: sha1-kQ3lDvzHwJ49gvL4er1rcAwYgYo=} - engines: {node: '>=0.10.0'} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - asynckit@0.4.0: - resolution: {integrity: sha1-x57Zf380y48robyXkLzDZkdLS3k=} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} - - cloudflare@5.2.0: - resolution: {integrity: sha512-dVzqDpPFYR9ApEC9e+JJshFJZXcw4HzM8W+3DHzO5oy9+8rLC53G7x6fEf9A7/gSuSCxuvndzui5qJKftfIM9A==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - croner@9.1.0: - resolution: {integrity: sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==} - engines: {node: '>=18.0'} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - date-fns@4.1.0: - resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} - - dayjs@1.11.19: - resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} - - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - - delayed-stream@1.0.0: - resolution: {integrity: sha1-3zrhmayt+31ECqrgsp4icrJOxhk=} - engines: {node: '>=0.4.0'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - escape-string-regexp@5.0.0: - resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} - engines: {node: '>=12'} - - event-target-shim@5.0.1: - resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} - engines: {node: '>=6'} - - fake-indexeddb@5.0.2: - resolution: {integrity: sha512-cB507r5T3D55DfclY01GLkninZLfU7HXV/mhVRTnTRm5k2u+fY7Fof2dBkr80p5t7G7dlA/G5dI87QiMdPpMCQ==} - engines: {node: '>=18'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - file-type@19.6.0: - resolution: {integrity: sha512-VZR5I7k5wkD0HgFnMsq5hOsSc710MJMu5Nc5QYsbe38NN5iPV/XTObYLc/cpttRTf6lX538+5uO1ZQRhYibiZQ==} - engines: {node: '>=18'} - - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - - form-data-encoder@1.7.2: - resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - - formdata-node@4.4.1: - resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} - engines: {node: '>= 12.20'} - - fs-extra@11.3.2: - resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} - engines: {node: '>=14.14'} - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-stream@9.0.1: - resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} - engines: {node: '>=18'} - - glob@11.1.0: - resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} - engines: {node: 20 || >=22} - hasBin: true - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - humanize-ms@1.2.1: - resolution: {integrity: sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=} - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-nan@1.3.2: - resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} - engines: {node: '>= 0.4'} - - is-stream@4.0.1: - resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} - engines: {node: '>=18'} - - isexe@2.0.0: - resolution: {integrity: sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=} - - jackspeak@4.1.1: - resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} - engines: {node: 20 || >=22} - - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} - - lodash.clonedeep@4.5.0: - resolution: {integrity: sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=} - - lru-cache@11.2.2: - resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} - engines: {node: 20 || >=22} - - matcher@5.0.0: - resolution: {integrity: sha512-s2EMBOWtXFc8dgqvoAzKJXxNHibcdJMV0gwqKUaw9E2JBJuGUK7DrNKrA6g/i+v72TT16+6sVm5mS3thaMLQUw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - mime@4.1.0: - resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} - engines: {node: '>=16'} - hasBin: true - - minimatch@10.1.1: - resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} - engines: {node: 20 || >=22} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} - - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} - engines: {node: '>=16 || 14 >=14.17'} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - node-domexception@1.0.0: - resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} - engines: {node: '>=10.5.0'} - - node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - - parse-ms@4.0.0: - resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} - engines: {node: '>=18'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-scurry@2.0.1: - resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} - engines: {node: 20 || >=22} - - peek-readable@5.4.2: - resolution: {integrity: sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==} - engines: {node: '>=14.16'} - - pretty-ms@9.3.0: - resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} - engines: {node: '>=18'} - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} - engines: {node: '>=12'} - - strtok3@9.1.1: - resolution: {integrity: sha512-FhwotcEqjr241ZbjFzjlIYg6c5/L/s4yBGWSMvJ9UoExiSqL+FnFA/CaeZx17WGaZMS/4SOZp8wH18jSS4R4lw==} - engines: {node: '>=16'} - - symbol-tree@3.2.4: - resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - - through2@4.0.2: - resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} - - token-types@6.1.1: - resolution: {integrity: sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==} - engines: {node: '>=14.16'} - - tr46@0.0.3: - resolution: {integrity: sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - - uint8array-extras@1.5.0: - resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} - engines: {node: '>=18'} - - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - - util-deprecate@1.0.2: - resolution: {integrity: sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=} - - web-streams-polyfill@4.0.0-beta.3: - resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} - engines: {node: '>= 14'} - - webidl-conversions@3.0.1: - resolution: {integrity: sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=} - - whatwg-url@5.0.0: - resolution: {integrity: sha1-lmRU6HZUYuN2RNNib2dCzotwll0=} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - -snapshots: - - '@api.global/typedrequest-interfaces@3.0.19': {} - - '@apiclient.xyz/cloudflare@6.4.3': - dependencies: - '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartlog': 3.1.10 - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smartrequest': 5.0.1 - '@push.rocks/smartstring': 4.1.0 - '@tsclass/tsclass': 9.3.0 - cloudflare: 5.2.0 - transitivePeerDependencies: - - encoding - - '@borewit/text-codec@0.1.1': {} - - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.0': - dependencies: - '@isaacs/balanced-match': 4.0.1 - - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - - '@push.rocks/consolecolor@2.0.3': - dependencies: - ansi-256-colors: 1.1.0 - - '@push.rocks/isounique@1.0.5': {} - - '@push.rocks/lik@6.2.2': - dependencies: - '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartmatch': 2.0.0 - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smartrx': 3.0.10 - '@push.rocks/smarttime': 4.1.1 - '@types/minimatch': 5.1.2 - '@types/symbol-tree': 3.2.5 - symbol-tree: 3.2.4 - - '@push.rocks/smartclickhouse@2.0.17': - dependencies: - '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartobject': 1.0.12 - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smartrx': 3.0.10 - '@push.rocks/smarturl': 3.1.0 - '@push.rocks/webrequest': 3.0.37 - - '@push.rocks/smartdelay@3.0.5': - dependencies: - '@push.rocks/smartpromise': 4.2.3 - - '@push.rocks/smartenv@5.0.13': - dependencies: - '@push.rocks/smartpromise': 4.2.3 - - '@push.rocks/smartenv@6.0.0': - dependencies: - '@push.rocks/smartpromise': 4.2.3 - - '@push.rocks/smartfile-interfaces@1.0.7': {} - - '@push.rocks/smartfile@11.2.7': - dependencies: - '@push.rocks/lik': 6.2.2 - '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartfile-interfaces': 1.0.7 - '@push.rocks/smarthash': 3.2.6 - '@push.rocks/smartjson': 5.2.0 - '@push.rocks/smartmime': 2.0.4 - '@push.rocks/smartpath': 6.0.0 - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smartrequest': 4.4.2 - '@push.rocks/smartstream': 3.2.5 - '@types/fs-extra': 11.0.4 - '@types/js-yaml': 4.0.9 - fs-extra: 11.3.2 - glob: 11.1.0 - js-yaml: 4.1.1 - - '@push.rocks/smarthash@3.2.6': - dependencies: - '@push.rocks/smartenv': 5.0.13 - '@push.rocks/smartjson': 5.2.0 - '@push.rocks/smartpromise': 4.2.3 - '@types/through2': 2.0.41 - through2: 4.0.2 - - '@push.rocks/smartjson@5.2.0': - dependencies: - '@push.rocks/smartenv': 5.0.13 - '@push.rocks/smartstring': 4.1.0 - fast-json-stable-stringify: 2.1.0 - lodash.clonedeep: 4.5.0 - - '@push.rocks/smartlog@3.1.10': - dependencies: - '@api.global/typedrequest-interfaces': 3.0.19 - '@push.rocks/consolecolor': 2.0.3 - '@push.rocks/isounique': 1.0.5 - '@push.rocks/smartclickhouse': 2.0.17 - '@push.rocks/smartfile': 11.2.7 - '@push.rocks/smarthash': 3.2.6 - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smarttime': 4.1.1 - '@push.rocks/webrequest': 3.0.37 - '@tsclass/tsclass': 9.3.0 - - '@push.rocks/smartmatch@2.0.0': - dependencies: - matcher: 5.0.0 - - '@push.rocks/smartmime@2.0.4': - dependencies: - '@types/mime-types': 2.1.4 - file-type: 19.6.0 - mime: 4.1.0 - - '@push.rocks/smartobject@1.0.12': - dependencies: - fast-deep-equal: 3.1.3 - minimatch: 9.0.5 - - '@push.rocks/smartpath@6.0.0': {} - - '@push.rocks/smartpromise@4.2.3': {} - - '@push.rocks/smartrequest@4.4.2': - dependencies: - '@push.rocks/smartenv': 6.0.0 - '@push.rocks/smartpath': 6.0.0 - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smarturl': 3.1.0 - agentkeepalive: 4.6.0 - form-data: 4.0.5 - - '@push.rocks/smartrequest@5.0.1': - dependencies: - '@push.rocks/smartenv': 6.0.0 - '@push.rocks/smartpath': 6.0.0 - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smarturl': 3.1.0 - agentkeepalive: 4.6.0 - form-data: 4.0.5 - - '@push.rocks/smartrx@3.0.10': - dependencies: - '@push.rocks/smartpromise': 4.2.3 - rxjs: 7.8.2 - - '@push.rocks/smartstream@3.2.5': - dependencies: - '@push.rocks/lik': 6.2.2 - '@push.rocks/smartenv': 5.0.13 - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smartrx': 3.0.10 - - '@push.rocks/smartstring@4.1.0': - dependencies: - '@push.rocks/isounique': 1.0.5 - - '@push.rocks/smarttime@4.1.1': - dependencies: - '@push.rocks/lik': 6.2.2 - '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartpromise': 4.2.3 - croner: 9.1.0 - date-fns: 4.1.0 - dayjs: 1.11.19 - is-nan: 1.3.2 - pretty-ms: 9.3.0 - - '@push.rocks/smarturl@3.1.0': {} - - '@push.rocks/webrequest@3.0.37': - dependencies: - '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartenv': 5.0.13 - '@push.rocks/smartjson': 5.2.0 - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/webstore': 2.0.20 - - '@push.rocks/webstore@2.0.20': - dependencies: - '@api.global/typedrequest-interfaces': 3.0.19 - '@push.rocks/lik': 6.2.2 - '@push.rocks/smartenv': 5.0.13 - '@push.rocks/smartjson': 5.2.0 - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smartrx': 3.0.10 - '@tempfix/idb': 8.0.3 - fake-indexeddb: 5.0.2 - - '@sec-ant/readable-stream@0.4.1': {} - - '@tempfix/idb@8.0.3': {} - - '@tokenizer/token@0.3.0': {} - - '@tsclass/tsclass@9.3.0': - dependencies: - type-fest: 4.41.0 - - '@types/fs-extra@11.0.4': - dependencies: - '@types/jsonfile': 6.1.4 - '@types/node': 24.10.1 - - '@types/js-yaml@4.0.9': {} - - '@types/jsonfile@6.1.4': - dependencies: - '@types/node': 24.10.1 - - '@types/mime-types@2.1.4': {} - - '@types/minimatch@5.1.2': {} - - '@types/node-fetch@2.6.13': - dependencies: - '@types/node': 18.19.130 - form-data: 4.0.5 - - '@types/node@18.19.130': - dependencies: - undici-types: 5.26.5 - - '@types/node@24.10.1': - dependencies: - undici-types: 7.16.0 - - '@types/symbol-tree@3.2.5': {} - - '@types/through2@2.0.41': - dependencies: - '@types/node': 24.10.1 - - abort-controller@3.0.0: - dependencies: - event-target-shim: 5.0.1 - - agentkeepalive@4.6.0: - dependencies: - humanize-ms: 1.2.1 - - ansi-256-colors@1.1.0: {} - - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@6.2.3: {} - - argparse@2.0.1: {} - - asynckit@0.4.0: {} - - balanced-match@1.0.2: {} - - brace-expansion@2.0.2: - dependencies: - balanced-match: 1.0.2 - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bind@1.0.8: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - - cloudflare@5.2.0: - dependencies: - '@types/node': 18.19.130 - '@types/node-fetch': 2.6.13 - abort-controller: 3.0.0 - agentkeepalive: 4.6.0 - form-data-encoder: 1.7.2 - formdata-node: 4.4.1 - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - croner@9.1.0: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - date-fns@4.1.0: {} - - dayjs@1.11.19: {} - - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - - delayed-stream@1.0.0: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - eastasianwidth@0.2.0: {} - - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - escape-string-regexp@5.0.0: {} - - event-target-shim@5.0.1: {} - - fake-indexeddb@5.0.2: {} - - fast-deep-equal@3.1.3: {} - - fast-json-stable-stringify@2.1.0: {} - - file-type@19.6.0: - dependencies: - get-stream: 9.0.1 - strtok3: 9.1.1 - token-types: 6.1.1 - uint8array-extras: 1.5.0 - - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - - form-data-encoder@1.7.2: {} - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - - formdata-node@4.4.1: - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 4.0.0-beta.3 - - fs-extra@11.3.2: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.0 - universalify: 2.0.1 - - function-bind@1.1.2: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-stream@9.0.1: - dependencies: - '@sec-ant/readable-stream': 0.4.1 - is-stream: 4.0.1 - - glob@11.1.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 4.1.1 - minimatch: 10.1.1 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 2.0.1 - - gopd@1.2.0: {} - - graceful-fs@4.2.11: {} - - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - humanize-ms@1.2.1: - dependencies: - ms: 2.1.3 - - ieee754@1.2.1: {} - - inherits@2.0.4: {} - - is-fullwidth-code-point@3.0.0: {} - - is-nan@1.3.2: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - - is-stream@4.0.1: {} - - isexe@2.0.0: {} - - jackspeak@4.1.1: - dependencies: - '@isaacs/cliui': 8.0.2 - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - jsonfile@6.2.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - - lodash.clonedeep@4.5.0: {} - - lru-cache@11.2.2: {} - - matcher@5.0.0: - dependencies: - escape-string-regexp: 5.0.0 - - math-intrinsics@1.1.0: {} - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - mime@4.1.0: {} - - minimatch@10.1.1: - dependencies: - '@isaacs/brace-expansion': 5.0.0 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.2 - - minipass@7.1.2: {} - - ms@2.1.3: {} - - node-domexception@1.0.0: {} - - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - - object-keys@1.1.1: {} - - package-json-from-dist@1.0.1: {} - - parse-ms@4.0.0: {} - - path-key@3.1.1: {} - - path-scurry@2.0.1: - dependencies: - lru-cache: 11.2.2 - minipass: 7.1.2 - - peek-readable@5.4.2: {} - - pretty-ms@9.3.0: - dependencies: - parse-ms: 4.0.0 - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - - safe-buffer@5.2.1: {} - - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - signal-exit@4.1.0: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.1.2 - - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.1.2: - dependencies: - ansi-regex: 6.2.2 - - strtok3@9.1.1: - dependencies: - '@tokenizer/token': 0.3.0 - peek-readable: 5.4.2 - - symbol-tree@3.2.4: {} - - through2@4.0.2: - dependencies: - readable-stream: 3.6.2 - - token-types@6.1.1: - dependencies: - '@borewit/text-codec': 0.1.1 - '@tokenizer/token': 0.3.0 - ieee754: 1.2.1 - - tr46@0.0.3: {} - - tslib@2.8.1: {} - - type-fest@4.41.0: {} - - uint8array-extras@1.5.0: {} - - undici-types@5.26.5: {} - - undici-types@7.16.0: {} - - universalify@2.0.1: {} - - util-deprecate@1.0.2: {} - - web-streams-polyfill@4.0.0-beta.3: {} - - webidl-conversions@3.0.1: {} - - whatwg-url@5.0.0: - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.1.2 diff --git a/ts/classes/cert-requirement-manager.ts b/ts/classes/cert-requirement-manager.ts new file mode 100644 index 0000000..082b7e0 --- /dev/null +++ b/ts/classes/cert-requirement-manager.ts @@ -0,0 +1,338 @@ +/** + * Certificate Requirement Manager + * + * Manages the lifecycle of SSL certificates based on service requirements. + * Automatically acquires, renews, and cleans up certificates. + */ + +import * as plugins from '../plugins.ts'; +import { logger } from '../logging.ts'; +import { OneboxDatabase } from './database.ts'; +import { OneboxSslManager } from './sslmanager.ts'; +import type { ICertRequirement, ICertificate, IDomain } from '../types.ts'; + +export class CertRequirementManager { + private database: OneboxDatabase; + private sslManager: OneboxSslManager; + + // Certificate renewal threshold (30 days before expiry) + private readonly RENEWAL_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; + + // Certificate cleanup delay (90 days after becoming invalid) + private readonly CLEANUP_DELAY_MS = 90 * 24 * 60 * 60 * 1000; + + constructor(database: OneboxDatabase, sslManager: OneboxSslManager) { + this.database = database; + this.sslManager = sslManager; + } + + /** + * Process all pending certificate requirements + * Matches requirements to existing certificates or schedules acquisition + */ + async processPendingRequirements(): Promise { + try { + const allRequirements = this.database.getAllCertRequirements(); + const pendingRequirements = allRequirements.filter((req) => req.status === 'pending'); + + logger.info(`Processing ${pendingRequirements.length} pending certificate requirement(s)`); + + for (const requirement of pendingRequirements) { + try { + await this.processRequirement(requirement); + } catch (error) { + logger.error( + `Failed to process requirement ${requirement.id}: ${error.message}` + ); + } + } + } catch (error) { + logger.error(`Failed to process pending requirements: ${error.message}`); + } + } + + /** + * Process a single certificate requirement + */ + private async processRequirement(requirement: ICertRequirement): Promise { + const domain = this.database.getDomainById(requirement.domainId); + if (!domain) { + logger.error(`Domain ${requirement.domainId} not found for requirement ${requirement.id}`); + return; + } + + // Construct the full domain name + const fullDomain = requirement.subdomain + ? `${requirement.subdomain}.${domain.domain}` + : domain.domain; + + logger.debug(`Processing requirement for domain: ${fullDomain}`); + + // Check if a valid certificate already exists + const existingCert = this.findValidCertificate(domain, requirement.subdomain); + + if (existingCert) { + // Link existing certificate to requirement + this.database.updateCertRequirement(requirement.id!, { + certificateId: existingCert.id, + status: 'active', + }); + logger.info(`Linked existing certificate to requirement for ${fullDomain}`); + } else { + // Schedule certificate acquisition + await this.acquireCertificate(requirement, domain, fullDomain); + } + } + + /** + * Find a valid certificate for the given domain and subdomain + */ + private findValidCertificate( + domain: IDomain, + subdomain: string + ): ICertificate | null { + const certificates = this.database.getCertificatesByDomain(domain.id!); + const now = Date.now(); + + for (const cert of certificates) { + // Skip invalid or expired certificates + if (!cert.isValid || cert.expiryDate <= now) { + continue; + } + + // Check if certificate covers the required domain + if (cert.isWildcard && !subdomain) { + // Wildcard cert covers base domain + return cert; + } else if (cert.isWildcard && subdomain) { + // Wildcard cert covers first-level subdomains + const levels = subdomain.split('.').length; + if (levels === 1) { + return cert; + } + } else if (!cert.isWildcard && cert.certDomain === domain.domain && !subdomain) { + // Exact match for base domain + return cert; + } else if (!cert.isWildcard && subdomain) { + // Exact match for specific subdomain + const fullDomain = `${subdomain}.${domain.domain}`; + if (cert.certDomain === fullDomain) { + return cert; + } + } + } + + return null; + } + + /** + * Acquire a new certificate for the requirement + */ + private async acquireCertificate( + requirement: ICertRequirement, + domain: IDomain, + fullDomain: string + ): Promise { + try { + logger.info(`Acquiring certificate for ${fullDomain}...`); + + // Determine if we should use wildcard + const useWildcard = domain.defaultWildcard && !requirement.subdomain; + + // Acquire certificate using SSL manager + const certData = await this.sslManager.acquireCertificate( + domain.domain, + useWildcard + ); + + // Store certificate in database + const now = Date.now(); + const certificate = this.database.createCertificate({ + domainId: domain.id!, + certDomain: domain.domain, + isWildcard: useWildcard, + certPath: certData.certPath, + keyPath: certData.keyPath, + fullChainPath: certData.fullChainPath, + expiryDate: certData.expiryDate, + issuer: certData.issuer, + isValid: true, + createdAt: now, + updatedAt: now, + }); + + // Link certificate to requirement + this.database.updateCertRequirement(requirement.id!, { + certificateId: certificate.id, + status: 'active', + }); + + logger.success(`Certificate acquired for ${fullDomain}`); + } catch (error) { + logger.error(`Failed to acquire certificate for ${fullDomain}: ${error.message}`); + throw error; + } + } + + /** + * Check all certificates for renewal + */ + async checkCertificateRenewal(): Promise { + try { + const allCertificates = this.database.getAllCertificates(); + const now = Date.now(); + + for (const cert of allCertificates) { + // Skip invalid certificates + if (!cert.isValid) { + continue; + } + + // Check if certificate is expired + if (cert.expiryDate <= now) { + // Mark as invalid + this.database.updateCertificate(cert.id!, { isValid: false }); + logger.warn(`Certificate ${cert.id} for ${cert.certDomain} has expired`); + continue; + } + + // Check if certificate needs renewal + const timeUntilExpiry = cert.expiryDate - now; + if (timeUntilExpiry <= this.RENEWAL_THRESHOLD_MS) { + await this.renewCertificate(cert); + } + } + } catch (error) { + logger.error(`Failed to check certificate renewal: ${error.message}`); + } + } + + /** + * Renew a certificate + */ + private async renewCertificate(cert: ICertificate): Promise { + try { + logger.info(`Renewing certificate for ${cert.certDomain}...`); + + const domain = this.database.getDomainById(cert.domainId); + if (!domain) { + logger.error(`Domain ${cert.domainId} not found for certificate ${cert.id}`); + return; + } + + // Mark all requirements using this certificate as renewing + const requirements = this.database.getAllCertRequirements(); + const relatedRequirements = requirements.filter( + (req) => req.certificateId === cert.id + ); + + for (const req of relatedRequirements) { + this.database.updateCertRequirement(req.id!, { status: 'renewing' }); + } + + // Acquire new certificate + const certData = await this.sslManager.acquireCertificate( + domain.domain, + cert.isWildcard + ); + + // Update certificate in database + this.database.updateCertificate(cert.id!, { + certPath: certData.certPath, + keyPath: certData.keyPath, + fullChainPath: certData.fullChainPath, + expiryDate: certData.expiryDate, + issuer: certData.issuer, + }); + + // Mark requirements as active again + for (const req of relatedRequirements) { + this.database.updateCertRequirement(req.id!, { status: 'active' }); + } + + logger.success(`Certificate renewed for ${cert.certDomain}`); + } catch (error) { + logger.error(`Failed to renew certificate for ${cert.certDomain}: ${error.message}`); + } + } + + /** + * Clean up old invalid certificates (90+ days old) + */ + async cleanupOldCertificates(): Promise { + try { + const allCertificates = this.database.getAllCertificates(); + const now = Date.now(); + let deletedCount = 0; + + for (const cert of allCertificates) { + // Only clean up invalid certificates + if (!cert.isValid) { + // Check if certificate has been invalid for 90+ days + const timeSinceExpiry = now - cert.expiryDate; + if (timeSinceExpiry >= this.CLEANUP_DELAY_MS) { + // Delete certificate files + try { + await Deno.remove(cert.certPath); + await Deno.remove(cert.keyPath); + await Deno.remove(cert.fullChainPath); + } catch (error) { + logger.debug( + `Failed to delete certificate files for ${cert.certDomain}: ${error.message}` + ); + } + + // Delete from database + this.database.deleteCertificate(cert.id!); + deletedCount++; + logger.info( + `Deleted old certificate ${cert.id} for ${cert.certDomain} (expired ${new Date( + cert.expiryDate + ).toISOString()})` + ); + } + } + } + + if (deletedCount > 0) { + logger.info(`Cleaned up ${deletedCount} old certificate(s)`); + } + } catch (error) { + logger.error(`Failed to cleanup old certificates: ${error.message}`); + } + } + + /** + * Get certificate status for a domain + */ + getCertificateStatus(domainId: number): { + valid: number; + expiringSoon: number; + expired: number; + pending: number; + } { + const certificates = this.database.getCertificatesByDomain(domainId); + const requirements = this.database.getCertRequirementsByDomain(domainId); + const now = Date.now(); + + let valid = 0; + let expiringSoon = 0; + let expired = 0; + + for (const cert of certificates) { + if (!cert.isValid) { + expired++; + } else if (cert.expiryDate <= now) { + expired++; + } else if (cert.expiryDate - now <= this.RENEWAL_THRESHOLD_MS) { + expiringSoon++; + } else { + valid++; + } + } + + const pending = requirements.filter((req) => req.status === 'pending').length; + + return { valid, expiringSoon, expired, pending }; + } +} diff --git a/ts/classes/certmanager.ts b/ts/classes/certmanager.ts new file mode 100644 index 0000000..70e9285 --- /dev/null +++ b/ts/classes/certmanager.ts @@ -0,0 +1,189 @@ +/** + * SQLite-based Certificate Manager for SmartACME + * + * Implements ICertManager interface to store SSL certificates in SQLite database + * and write PEM files to filesystem for use by the reverse proxy. + */ + +import * as plugins from '../plugins.ts'; +import { logger } from '../logging.ts'; +import { OneboxDatabase } from './database.ts'; + +export class SqliteCertManager implements plugins.smartacme.ICertManager { + private database: OneboxDatabase; + private certBasePath: string; + + constructor(database: OneboxDatabase, certBasePath = './.nogit/ssl/live') { + this.database = database; + this.certBasePath = certBasePath; + } + + /** + * Initialize the certificate manager + */ + async init(): Promise { + try { + // Ensure certificate directory exists + await Deno.mkdir(this.certBasePath, { recursive: true }); + logger.info(`Certificate manager initialized (path: ${this.certBasePath})`); + } catch (error) { + logger.error(`Failed to initialize certificate manager: ${error.message}`); + throw error; + } + } + + /** + * Retrieve a certificate by domain name + */ + async retrieveCertificate(domainName: string): Promise { + try { + const dbCert = this.database.getSSLCertificate(domainName); + + if (!dbCert) { + return null; + } + + // Convert database format to SmartacmeCert format + const cert = new plugins.smartacme.Cert({ + id: dbCert.id?.toString() || domainName, + domainName: dbCert.domain, + created: dbCert.createdAt, + privateKey: await this.readPemFile(dbCert.keyPath), + publicKey: await this.readPemFile(dbCert.fullChainPath), // Full chain as public key + csr: '', // CSR not stored separately + validUntil: dbCert.expiryDate, + }); + + return cert; + } catch (error) { + logger.warn(`Failed to retrieve certificate for ${domainName}: ${error.message}`); + return null; + } + } + + /** + * Store a certificate + */ + async storeCertificate(cert: plugins.smartacme.Cert): Promise { + try { + const domain = cert.domainName; + const domainPath = `${this.certBasePath}/${domain}`; + + // Create domain-specific directory + await Deno.mkdir(domainPath, { recursive: true }); + + // Write PEM files + const keyPath = `${domainPath}/privkey.pem`; + const certPath = `${domainPath}/cert.pem`; + const fullChainPath = `${domainPath}/fullchain.pem`; + + await Deno.writeTextFile(keyPath, cert.privateKey); + await Deno.writeTextFile(fullChainPath, cert.publicKey); + + // Extract certificate from full chain (first certificate in the chain) + const certOnly = this.extractCertFromChain(cert.publicKey); + await Deno.writeTextFile(certPath, certOnly); + + // Store/update in database + const existing = this.database.getSSLCertificate(domain); + + if (existing) { + this.database.updateSSLCertificate(domain, { + certPath, + keyPath, + fullChainPath, + expiryDate: cert.validUntil, + updatedAt: Date.now(), + }); + } else { + await this.database.createSSLCertificate({ + domain, + certPath, + keyPath, + fullChainPath, + expiryDate: cert.validUntil, + issuer: 'Let\'s Encrypt', + createdAt: cert.created, + updatedAt: Date.now(), + }); + } + + logger.success(`Certificate stored for ${domain}`); + } catch (error) { + logger.error(`Failed to store certificate for ${cert.domainName}: ${error.message}`); + throw error; + } + } + + /** + * Delete a certificate + */ + async deleteCertificate(domainName: string): Promise { + try { + const dbCert = this.database.getSSLCertificate(domainName); + + if (dbCert) { + // Delete PEM files + const domainPath = `${this.certBasePath}/${domainName}`; + try { + await Deno.remove(domainPath, { recursive: true }); + } catch (error) { + logger.warn(`Failed to delete PEM files for ${domainName}: ${error.message}`); + } + + // Delete from database + this.database.deleteSSLCertificate(domainName); + + logger.info(`Certificate deleted for ${domainName}`); + } + } catch (error) { + logger.error(`Failed to delete certificate for ${domainName}: ${error.message}`); + throw error; + } + } + + /** + * Close the certificate manager + */ + async close(): Promise { + // SQLite database is managed by OneboxDatabase, nothing to close here + logger.info('Certificate manager closed'); + } + + /** + * Wipe all certificates (for testing) + */ + async wipe(): Promise { + try { + const certs = this.database.getAllSSLCertificates(); + + for (const cert of certs) { + await this.deleteCertificate(cert.domain); + } + + logger.warn('All certificates wiped'); + } catch (error) { + logger.error(`Failed to wipe certificates: ${error.message}`); + throw error; + } + } + + /** + * Read PEM file from filesystem + */ + private async readPemFile(path: string): Promise { + try { + return await Deno.readTextFile(path); + } catch (error) { + throw new Error(`Failed to read PEM file ${path}: ${error.message}`); + } + } + + /** + * Extract the first certificate from a PEM chain + */ + private extractCertFromChain(fullChain: string): string { + const certMatch = fullChain.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/); + return certMatch ? certMatch[0] : fullChain; + } +} diff --git a/ts/classes/cloudflare-sync.ts b/ts/classes/cloudflare-sync.ts new file mode 100644 index 0000000..a504b4a --- /dev/null +++ b/ts/classes/cloudflare-sync.ts @@ -0,0 +1,165 @@ +/** + * Cloudflare Domain Sync Manager + * + * Synchronizes Cloudflare DNS zones with the local Domain table. + * Automatically imports zones and marks obsolete domains when zones are removed. + */ + +import * as plugins from '../plugins.ts'; +import { logger } from '../logging.ts'; +import { OneboxDatabase } from './database.ts'; +import type { IDomain } from '../types.ts'; + +export class CloudflareDomainSync { + private database: OneboxDatabase; + private cloudflareAccount: plugins.cloudflare.CloudflareAccount | null = null; + + constructor(database: OneboxDatabase) { + this.database = database; + } + + /** + * Initialize Cloudflare connection + */ + async init(): Promise { + try { + const apiKey = this.database.getSetting('cloudflareAPIKey'); + + if (!apiKey) { + logger.warn('Cloudflare API key not configured. Domain sync will be limited.'); + return; + } + + this.cloudflareAccount = new plugins.cloudflare.CloudflareAccount(apiKey); + logger.info('Cloudflare domain sync initialized'); + } catch (error) { + logger.error(`Failed to initialize Cloudflare sync: ${error.message}`); + throw error; + } + } + + /** + * Check if Cloudflare is configured + */ + isConfigured(): boolean { + return this.cloudflareAccount !== null; + } + + /** + * Sync all Cloudflare zones with Domain table + */ + async syncZones(): Promise { + if (!this.isConfigured()) { + logger.warn('Cloudflare not configured, skipping zone sync'); + return; + } + + try { + logger.info('Starting Cloudflare zone synchronization...'); + + // Fetch all zones from Cloudflare + const zones = await this.cloudflareAccount!.getZones(); + logger.info(`Found ${zones.length} Cloudflare zone(s)`); + + const now = Date.now(); + const syncedZoneIds = new Set(); + + // Sync each zone to the Domain table + for (const zone of zones) { + try { + const domain = zone.name; + const zoneId = zone.id; + + syncedZoneIds.add(zoneId); + + // Check if domain already exists + const existingDomain = this.database.getDomainByName(domain); + + if (existingDomain) { + // Update existing domain + this.database.updateDomain(existingDomain.id!, { + dnsProvider: 'cloudflare', + cloudflareZoneId: zoneId, + isObsolete: false, // Re-activate if it was marked obsolete + updatedAt: now, + }); + logger.debug(`Updated domain: ${domain}`); + } else { + // Create new domain + this.database.createDomain({ + domain, + dnsProvider: 'cloudflare', + cloudflareZoneId: zoneId, + isObsolete: false, + defaultWildcard: true, // Default to wildcard certificates + createdAt: now, + updatedAt: now, + }); + logger.info(`Added new domain from Cloudflare: ${domain}`); + } + } catch (error) { + logger.error(`Failed to sync zone ${zone.name}: ${error.message}`); + } + } + + // Mark domains as obsolete if their Cloudflare zones no longer exist + await this.markObsoleteDomains(syncedZoneIds); + + logger.success(`Cloudflare zone sync completed: ${zones.length} zone(s) synced`); + } catch (error) { + logger.error(`Cloudflare zone sync failed: ${error.message}`); + throw error; + } + } + + /** + * Mark domains as obsolete if their Cloudflare zones have been removed + */ + private async markObsoleteDomains(activeZoneIds: Set): Promise { + try { + // Get all domains managed by Cloudflare + const cloudflareDomains = this.database.getDomainsByProvider('cloudflare'); + + let obsoleteCount = 0; + + for (const domain of cloudflareDomains) { + // If domain has a Cloudflare zone ID but it's not in the active set, mark obsolete + if (domain.cloudflareZoneId && !activeZoneIds.has(domain.cloudflareZoneId)) { + this.database.updateDomain(domain.id!, { + isObsolete: true, + updatedAt: Date.now(), + }); + logger.warn(`Marked domain as obsolete (zone removed): ${domain.domain}`); + obsoleteCount++; + } + } + + if (obsoleteCount > 0) { + logger.info(`Marked ${obsoleteCount} domain(s) as obsolete`); + } + } catch (error) { + logger.error(`Failed to mark obsolete domains: ${error.message}`); + } + } + + /** + * Get sync status information + */ + getSyncStatus(): { + configured: boolean; + totalDomains: number; + cloudflareDomains: number; + obsoleteDomains: number; + } { + const allDomains = this.database.getAllDomains(); + const cloudflareDomains = allDomains.filter(d => d.dnsProvider === 'cloudflare'); + const obsoleteDomains = allDomains.filter(d => d.isObsolete); + + return { + configured: this.isConfigured(), + totalDomains: allDomains.length, + cloudflareDomains: cloudflareDomains.length, + obsoleteDomains: obsoleteDomains.length, + }; + } +} diff --git a/ts/classes/database.ts b/ts/classes/database.ts index 5d57307..757cb4d 100644 --- a/ts/classes/database.ts +++ b/ts/classes/database.ts @@ -204,15 +204,313 @@ export class OneboxDatabase { private async runMigrations(): Promise { if (!this.db) throw new Error('Database not initialized'); - const currentVersion = this.getMigrationVersion(); - logger.debug(`Current database version: ${currentVersion}`); + try { + const currentVersion = this.getMigrationVersion(); + logger.info(`Current database migration version: ${currentVersion}`); - // Add migration logic here as needed - // For now, just set version to 1 - if (currentVersion === 0) { - this.setMigrationVersion(1); + // Migration 1: Initial schema + if (currentVersion === 0) { + logger.info('Setting initial migration version to 1'); + this.setMigrationVersion(1); + } + + // Migration 2: Convert timestamp columns from INTEGER to REAL + const updatedVersion = this.getMigrationVersion(); + if (updatedVersion < 2) { + logger.info('Running migration 2: Converting timestamps to REAL...'); + + // For each table, we need to: + // 1. Create new table with REAL timestamps + // 2. Copy data + // 3. Drop old table + // 4. Rename new table + + // SSL certificates + this.query(` + CREATE TABLE ssl_certificates_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL UNIQUE, + cert_path TEXT NOT NULL, + key_path TEXT NOT NULL, + full_chain_path TEXT NOT NULL, + expiry_date REAL NOT NULL, + issuer TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + this.query(`INSERT INTO ssl_certificates_new SELECT * FROM ssl_certificates`); + this.query(`DROP TABLE ssl_certificates`); + this.query(`ALTER TABLE ssl_certificates_new RENAME TO ssl_certificates`); + + // Services + this.query(` + CREATE TABLE services_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + image TEXT NOT NULL, + registry TEXT, + env_vars TEXT NOT NULL, + port INTEGER NOT NULL, + domain TEXT, + container_id TEXT, + status TEXT NOT NULL DEFAULT 'stopped', + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + this.query(`INSERT INTO services_new SELECT * FROM services`); + this.query(`DROP TABLE services`); + this.query(`ALTER TABLE services_new RENAME TO services`); + + // Registries + this.query(` + CREATE TABLE registries_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL UNIQUE, + username TEXT NOT NULL, + password_encrypted TEXT NOT NULL, + created_at REAL NOT NULL + ) + `); + this.query(`INSERT INTO registries_new SELECT * FROM registries`); + this.query(`DROP TABLE registries`); + this.query(`ALTER TABLE registries_new RENAME TO registries`); + + // Nginx configs + this.query(` + CREATE TABLE nginx_configs_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + domain TEXT NOT NULL, + port INTEGER NOT NULL, + ssl_enabled INTEGER NOT NULL DEFAULT 0, + config_template TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + this.query(`INSERT INTO nginx_configs_new SELECT * FROM nginx_configs`); + this.query(`DROP TABLE nginx_configs`); + this.query(`ALTER TABLE nginx_configs_new RENAME TO nginx_configs`); + + // DNS records + this.query(` + CREATE TABLE dns_records_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + value TEXT NOT NULL, + cloudflare_id TEXT, + zone_id TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + this.query(`INSERT INTO dns_records_new SELECT * FROM dns_records`); + this.query(`DROP TABLE dns_records`); + this.query(`ALTER TABLE dns_records_new RENAME TO dns_records`); + + // Metrics + this.query(` + CREATE TABLE metrics_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + timestamp REAL NOT NULL, + cpu_percent REAL NOT NULL, + memory_used INTEGER NOT NULL, + memory_limit INTEGER NOT NULL, + network_rx_bytes INTEGER NOT NULL, + network_tx_bytes INTEGER NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + this.query(`INSERT INTO metrics_new SELECT * FROM metrics`); + this.query(`DROP TABLE metrics`); + this.query(`ALTER TABLE metrics_new RENAME TO metrics`); + this.query(`CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp ON metrics(service_id, timestamp DESC)`); + + // Logs + this.query(` + CREATE TABLE logs_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + timestamp REAL NOT NULL, + message TEXT NOT NULL, + level TEXT NOT NULL, + source TEXT NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + this.query(`INSERT INTO logs_new SELECT * FROM logs`); + this.query(`DROP TABLE logs`); + this.query(`ALTER TABLE logs_new RENAME TO logs`); + this.query(`CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp ON logs(service_id, timestamp DESC)`); + + // Users + this.query(` + CREATE TABLE users_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + this.query(`INSERT INTO users_new SELECT * FROM users`); + this.query(`DROP TABLE users`); + this.query(`ALTER TABLE users_new RENAME TO users`); + + // Settings + this.query(` + CREATE TABLE settings_new ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at REAL NOT NULL + ) + `); + this.query(`INSERT INTO settings_new SELECT * FROM settings`); + this.query(`DROP TABLE settings`); + this.query(`ALTER TABLE settings_new RENAME TO settings`); + + // Migrations table itself + this.query(` + CREATE TABLE migrations_new ( + version INTEGER PRIMARY KEY, + applied_at REAL NOT NULL + ) + `); + this.query(`INSERT INTO migrations_new SELECT * FROM migrations`); + this.query(`DROP TABLE migrations`); + this.query(`ALTER TABLE migrations_new RENAME TO migrations`); + + this.setMigrationVersion(2); + logger.success('Migration 2 completed: All timestamps converted to REAL'); } + + // Migration 3: Domain management tables + const version3 = this.getMigrationVersion(); + if (version3 < 3) { + logger.info('Running migration 3: Creating domain management tables...'); + + // 1. Create domains table + this.query(` + CREATE TABLE domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL UNIQUE, + dns_provider TEXT, + cloudflare_zone_id TEXT, + is_obsolete INTEGER NOT NULL DEFAULT 0, + default_wildcard INTEGER NOT NULL DEFAULT 1, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + + // 2. Create certificates table (renamed from ssl_certificates) + this.query(` + CREATE TABLE certificates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id INTEGER NOT NULL, + cert_domain TEXT NOT NULL, + is_wildcard INTEGER NOT NULL DEFAULT 0, + cert_path TEXT NOT NULL, + key_path TEXT NOT NULL, + full_chain_path TEXT NOT NULL, + expiry_date REAL NOT NULL, + issuer TEXT NOT NULL, + is_valid INTEGER NOT NULL DEFAULT 1, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE + ) + `); + + // 3. Create cert_requirements table + this.query(` + CREATE TABLE cert_requirements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + domain_id INTEGER NOT NULL, + subdomain TEXT NOT NULL, + certificate_id INTEGER, + status TEXT NOT NULL DEFAULT 'pending', + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE, + FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE, + FOREIGN KEY (certificate_id) REFERENCES certificates(id) ON DELETE SET NULL + ) + `); + + // 4. Migrate existing ssl_certificates data + // Extract unique base domains from existing certificates + const existingCerts = this.query('SELECT * FROM ssl_certificates'); + + const now = Date.now(); + const domainMap = new Map(); + + // Create domain entries for each unique base domain + for (const cert of existingCerts) { + const domain = String(cert.domain ?? cert[1]); + if (!domainMap.has(domain)) { + this.query( + 'INSERT INTO domains (domain, dns_provider, is_obsolete, default_wildcard, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [domain, null, 0, 1, now, now] + ); + const result = this.query('SELECT last_insert_rowid() as id'); + const domainId = result[0].id ?? result[0][0]; + domainMap.set(domain, Number(domainId)); + } + } + + // Migrate certificates to new table + for (const cert of existingCerts) { + const domain = String(cert.domain ?? cert[1]); + const domainId = domainMap.get(domain); + + this.query( + `INSERT INTO certificates ( + domain_id, cert_domain, is_wildcard, cert_path, key_path, full_chain_path, + expiry_date, issuer, is_valid, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + domainId, + domain, + 0, // We don't know if it's wildcard, default to false + String(cert.cert_path ?? cert[2]), + String(cert.key_path ?? cert[3]), + String(cert.full_chain_path ?? cert[4]), + Number(cert.expiry_date ?? cert[5]), + String(cert.issuer ?? cert[6]), + 1, // Assume valid + Number(cert.created_at ?? cert[7]), + Number(cert.updated_at ?? cert[8]) + ] + ); + } + + // 5. Drop old ssl_certificates table + this.query('DROP TABLE ssl_certificates'); + + // 6. Create indices for performance + this.query('CREATE INDEX IF NOT EXISTS idx_domains_cloudflare_zone ON domains(cloudflare_zone_id)'); + this.query('CREATE INDEX IF NOT EXISTS idx_certificates_domain ON certificates(domain_id)'); + this.query('CREATE INDEX IF NOT EXISTS idx_certificates_expiry ON certificates(expiry_date)'); + this.query('CREATE INDEX IF NOT EXISTS idx_cert_requirements_service ON cert_requirements(service_id)'); + this.query('CREATE INDEX IF NOT EXISTS idx_cert_requirements_domain ON cert_requirements(domain_id)'); + + this.setMigrationVersion(3); + logger.success('Migration 3 completed: Domain management tables created'); + } + } catch (error) { + logger.error(`Migration failed: ${error.message}`); + logger.error(`Stack: ${error.stack}`); + throw error; } +} /** * Get current migration version @@ -222,8 +520,13 @@ export class OneboxDatabase { try { const result = this.query('SELECT MAX(version) as version FROM migrations'); - return result.length > 0 && result[0][0] !== null ? Number(result[0][0]) : 0; - } catch { + if (result.length === 0) return 0; + + // Handle both array and object access patterns + const versionValue = result[0].version ?? result[0][0]; + return versionValue !== null && versionValue !== undefined ? Number(versionValue) : 0; + } catch (error) { + logger.warn(`Error getting migration version: ${error.message}, defaulting to 0`); return 0; } } @@ -695,4 +998,321 @@ export class OneboxDatabase { updatedAt: Number(row.updated_at || row[8]), }; } + + // ============ Domains ============ + + createDomain(domain: Omit): IDomain { + if (!this.db) throw new Error('Database not initialized'); + + this.query( + `INSERT INTO domains (domain, dns_provider, cloudflare_zone_id, is_obsolete, default_wildcard, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + domain.domain, + domain.dnsProvider, + domain.cloudflareZoneId, + domain.isObsolete ? 1 : 0, + domain.defaultWildcard ? 1 : 0, + domain.createdAt, + domain.updatedAt, + ] + ); + + return this.getDomainByName(domain.domain)!; + } + + getDomainByName(domain: string): IDomain | null { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM domains WHERE domain = ?', [domain]); + return rows.length > 0 ? this.rowToDomain(rows[0]) : null; + } + + getDomainById(id: number): IDomain | null { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM domains WHERE id = ?', [id]); + return rows.length > 0 ? this.rowToDomain(rows[0]) : null; + } + + getAllDomains(): IDomain[] { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM domains ORDER BY domain ASC'); + return rows.map((row) => this.rowToDomain(row)); + } + + getDomainsByProvider(provider: 'cloudflare' | 'manual'): IDomain[] { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM domains WHERE dns_provider = ? ORDER BY domain ASC', [ + provider, + ]); + return rows.map((row) => this.rowToDomain(row)); + } + + updateDomain(id: number, updates: Partial): void { + if (!this.db) throw new Error('Database not initialized'); + + const fields: string[] = []; + const values: unknown[] = []; + + if (updates.domain !== undefined) { + fields.push('domain = ?'); + values.push(updates.domain); + } + if (updates.dnsProvider !== undefined) { + fields.push('dns_provider = ?'); + values.push(updates.dnsProvider); + } + if (updates.cloudflareZoneId !== undefined) { + fields.push('cloudflare_zone_id = ?'); + values.push(updates.cloudflareZoneId); + } + if (updates.isObsolete !== undefined) { + fields.push('is_obsolete = ?'); + values.push(updates.isObsolete ? 1 : 0); + } + if (updates.defaultWildcard !== undefined) { + fields.push('default_wildcard = ?'); + values.push(updates.defaultWildcard ? 1 : 0); + } + + fields.push('updated_at = ?'); + values.push(Date.now()); + values.push(id); + + this.query(`UPDATE domains SET ${fields.join(', ')} WHERE id = ?`, values); + } + + deleteDomain(id: number): void { + if (!this.db) throw new Error('Database not initialized'); + this.query('DELETE FROM domains WHERE id = ?', [id]); + } + + private rowToDomain(row: any): IDomain { + return { + id: Number(row.id || row[0]), + domain: String(row.domain || row[1]), + dnsProvider: (row.dns_provider || row[2]) as IDomain['dnsProvider'], + cloudflareZoneId: row.cloudflare_zone_id || row[3] || undefined, + isObsolete: Boolean(row.is_obsolete || row[4]), + defaultWildcard: Boolean(row.default_wildcard || row[5]), + createdAt: Number(row.created_at || row[6]), + updatedAt: Number(row.updated_at || row[7]), + }; + } + + // ============ Certificates ============ + + createCertificate(cert: Omit): ICertificate { + if (!this.db) throw new Error('Database not initialized'); + + this.query( + `INSERT INTO certificates (domain_id, cert_domain, is_wildcard, cert_path, key_path, full_chain_path, expiry_date, issuer, is_valid, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + cert.domainId, + cert.certDomain, + cert.isWildcard ? 1 : 0, + cert.certPath, + cert.keyPath, + cert.fullChainPath, + cert.expiryDate, + cert.issuer, + cert.isValid ? 1 : 0, + cert.createdAt, + cert.updatedAt, + ] + ); + + const rows = this.query('SELECT * FROM certificates WHERE id = last_insert_rowid()'); + return this.rowToCertificate(rows[0]); + } + + getCertificateById(id: number): ICertificate | null { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM certificates WHERE id = ?', [id]); + return rows.length > 0 ? this.rowToCertificate(rows[0]) : null; + } + + getCertificatesByDomain(domainId: number): ICertificate[] { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM certificates WHERE domain_id = ? ORDER BY expiry_date DESC', [ + domainId, + ]); + return rows.map((row) => this.rowToCertificate(row)); + } + + getAllCertificates(): ICertificate[] { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM certificates ORDER BY expiry_date DESC'); + return rows.map((row) => this.rowToCertificate(row)); + } + + updateCertificate(id: number, updates: Partial): void { + if (!this.db) throw new Error('Database not initialized'); + + const fields: string[] = []; + const values: unknown[] = []; + + if (updates.certDomain !== undefined) { + fields.push('cert_domain = ?'); + values.push(updates.certDomain); + } + if (updates.isWildcard !== undefined) { + fields.push('is_wildcard = ?'); + values.push(updates.isWildcard ? 1 : 0); + } + if (updates.certPath !== undefined) { + fields.push('cert_path = ?'); + values.push(updates.certPath); + } + if (updates.keyPath !== undefined) { + fields.push('key_path = ?'); + values.push(updates.keyPath); + } + if (updates.fullChainPath !== undefined) { + fields.push('full_chain_path = ?'); + values.push(updates.fullChainPath); + } + if (updates.expiryDate !== undefined) { + fields.push('expiry_date = ?'); + values.push(updates.expiryDate); + } + if (updates.issuer !== undefined) { + fields.push('issuer = ?'); + values.push(updates.issuer); + } + if (updates.isValid !== undefined) { + fields.push('is_valid = ?'); + values.push(updates.isValid ? 1 : 0); + } + + fields.push('updated_at = ?'); + values.push(Date.now()); + values.push(id); + + this.query(`UPDATE certificates SET ${fields.join(', ')} WHERE id = ?`, values); + } + + deleteCertificate(id: number): void { + if (!this.db) throw new Error('Database not initialized'); + this.query('DELETE FROM certificates WHERE id = ?', [id]); + } + + private rowToCertificate(row: any): ICertificate { + return { + id: Number(row.id || row[0]), + domainId: Number(row.domain_id || row[1]), + certDomain: String(row.cert_domain || row[2]), + isWildcard: Boolean(row.is_wildcard || row[3]), + certPath: String(row.cert_path || row[4]), + keyPath: String(row.key_path || row[5]), + fullChainPath: String(row.full_chain_path || row[6]), + expiryDate: Number(row.expiry_date || row[7]), + issuer: String(row.issuer || row[8]), + isValid: Boolean(row.is_valid || row[9]), + createdAt: Number(row.created_at || row[10]), + updatedAt: Number(row.updated_at || row[11]), + }; + } + + // ============ Certificate Requirements ============ + + createCertRequirement(req: Omit): ICertRequirement { + if (!this.db) throw new Error('Database not initialized'); + + this.query( + `INSERT INTO cert_requirements (service_id, domain_id, subdomain, certificate_id, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + req.serviceId, + req.domainId, + req.subdomain, + req.certificateId, + req.status, + req.createdAt, + req.updatedAt, + ] + ); + + const rows = this.query('SELECT * FROM cert_requirements WHERE id = last_insert_rowid()'); + return this.rowToCertRequirement(rows[0]); + } + + getCertRequirementById(id: number): ICertRequirement | null { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM cert_requirements WHERE id = ?', [id]); + return rows.length > 0 ? this.rowToCertRequirement(rows[0]) : null; + } + + getCertRequirementsByService(serviceId: number): ICertRequirement[] { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM cert_requirements WHERE service_id = ?', [serviceId]); + return rows.map((row) => this.rowToCertRequirement(row)); + } + + getCertRequirementsByDomain(domainId: number): ICertRequirement[] { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM cert_requirements WHERE domain_id = ?', [domainId]); + return rows.map((row) => this.rowToCertRequirement(row)); + } + + getAllCertRequirements(): ICertRequirement[] { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM cert_requirements ORDER BY created_at DESC'); + return rows.map((row) => this.rowToCertRequirement(row)); + } + + updateCertRequirement(id: number, updates: Partial): void { + if (!this.db) throw new Error('Database not initialized'); + + const fields: string[] = []; + const values: unknown[] = []; + + if (updates.subdomain !== undefined) { + fields.push('subdomain = ?'); + values.push(updates.subdomain); + } + if (updates.certificateId !== undefined) { + fields.push('certificate_id = ?'); + values.push(updates.certificateId); + } + if (updates.status !== undefined) { + fields.push('status = ?'); + values.push(updates.status); + } + + fields.push('updated_at = ?'); + values.push(Date.now()); + values.push(id); + + this.query(`UPDATE cert_requirements SET ${fields.join(', ')} WHERE id = ?`, values); + } + + deleteCertRequirement(id: number): void { + if (!this.db) throw new Error('Database not initialized'); + this.query('DELETE FROM cert_requirements WHERE id = ?', [id]); + } + + private rowToCertRequirement(row: any): ICertRequirement { + return { + id: Number(row.id || row[0]), + serviceId: Number(row.service_id || row[1]), + domainId: Number(row.domain_id || row[2]), + subdomain: String(row.subdomain || row[3]), + certificateId: row.certificate_id || row[4] || undefined, + status: String(row.status || row[5]) as ICertRequirement['status'], + createdAt: Number(row.created_at || row[6]), + updatedAt: Number(row.updated_at || row[7]), + }; + } } diff --git a/ts/classes/httpserver.ts b/ts/classes/httpserver.ts index fec7c16..428edde 100644 --- a/ts/classes/httpserver.ts +++ b/ts/classes/httpserver.ts @@ -214,6 +214,16 @@ export class OneboxHttpServer { } else if (path.match(/^\/api\/services\/[^/]+\/logs$/) && method === 'GET') { const name = path.split('/')[3]; return await this.handleGetLogsRequest(name); + } else if (path === '/api/ssl/obtain' && method === 'POST') { + return await this.handleObtainCertificateRequest(req); + } else if (path === '/api/ssl/list' && method === 'GET') { + return await this.handleListCertificatesRequest(); + } else if (path.match(/^\/api\/ssl\/[^/]+$/) && method === 'GET') { + const domain = path.split('/').pop()!; + return await this.handleGetCertificateRequest(domain); + } else if (path.match(/^\/api\/ssl\/[^/]+\/renew$/) && method === 'POST') { + const domain = path.split('/')[3]; + return await this.handleRenewCertificateRequest(domain); } else { return this.jsonResponse({ success: false, error: 'Not found' }, 404); } @@ -442,6 +452,66 @@ export class OneboxHttpServer { } } + private async handleObtainCertificateRequest(req: Request): Promise { + try { + const body = await req.json(); + const { domain, includeWildcard } = body; + + if (!domain) { + return this.jsonResponse( + { success: false, error: 'Domain is required' }, + 400 + ); + } + + await this.oneboxRef.ssl.obtainCertificate(domain, includeWildcard || false); + + return this.jsonResponse({ + success: true, + message: `Certificate obtained for ${domain}`, + }); + } catch (error) { + logger.error(`Failed to obtain certificate: ${error.message}`); + return this.jsonResponse({ success: false, error: error.message || 'Failed to obtain certificate' }, 500); + } + } + + private async handleListCertificatesRequest(): Promise { + try { + const certificates = this.oneboxRef.ssl.listCertificates(); + return this.jsonResponse({ success: true, data: certificates }); + } catch (error) { + logger.error(`Failed to list certificates: ${error.message}`); + return this.jsonResponse({ success: false, error: error.message || 'Failed to list certificates' }, 500); + } + } + + private async handleGetCertificateRequest(domain: string): Promise { + try { + const certificate = this.oneboxRef.ssl.getCertificate(domain); + if (!certificate) { + return this.jsonResponse({ success: false, error: 'Certificate not found' }, 404); + } + return this.jsonResponse({ success: true, data: certificate }); + } catch (error) { + logger.error(`Failed to get certificate for ${domain}: ${error.message}`); + return this.jsonResponse({ success: false, error: error.message || 'Failed to get certificate' }, 500); + } + } + + private async handleRenewCertificateRequest(domain: string): Promise { + try { + await this.oneboxRef.ssl.renewCertificate(domain); + return this.jsonResponse({ + success: true, + message: `Certificate renewed for ${domain}`, + }); + } catch (error) { + logger.error(`Failed to renew certificate for ${domain}: ${error.message}`); + return this.jsonResponse({ success: false, error: error.message || 'Failed to renew certificate' }, 500); + } + } + /** * Handle WebSocket upgrade */ diff --git a/ts/classes/ssl.ts b/ts/classes/ssl.ts index 5e59d3c..21f3488 100644 --- a/ts/classes/ssl.ts +++ b/ts/classes/ssl.ts @@ -7,11 +7,13 @@ import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; import { OneboxDatabase } from './database.ts'; +import { SqliteCertManager } from './certmanager.ts'; export class OneboxSslManager { private oneboxRef: any; private database: OneboxDatabase; private smartacme: plugins.smartacme.SmartAcme | null = null; + private certManager: SqliteCertManager | null = null; private acmeEmail: string | null = null; constructor(oneboxRef: any) { @@ -35,14 +37,45 @@ export class OneboxSslManager { this.acmeEmail = acmeEmail; - // Initialize SmartACME + // Get Cloudflare API key (reuse from DNS manager) + const cfApiKey = this.database.getSetting('cloudflareAPIKey'); + + if (!cfApiKey) { + logger.warn('Cloudflare API key not configured. SSL certificate management will be limited.'); + logger.info('Configure with: onebox config set cloudflareAPIKey '); + return; + } + + // Get ACME environment (default: production) + const acmeEnvironment = this.database.getSetting('acmeEnvironment') || 'production'; + + if (!['production', 'integration'].includes(acmeEnvironment)) { + throw new Error('acmeEnvironment must be "production" or "integration"'); + } + + // Initialize certificate manager + this.certManager = new SqliteCertManager(this.database); + await this.certManager.init(); + + // Initialize Cloudflare DNS provider for DNS-01 challenge + const cfAccount = new plugins.cloudflare.CloudflareAccount(cfApiKey); + + // Create DNS-01 challenge handler + const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(cfAccount); + + // Initialize SmartACME with proper configuration this.smartacme = new plugins.smartacme.SmartAcme({ - email: acmeEmail, - environment: 'production', // or 'staging' for testing - dns: 'cloudflare', // Use Cloudflare DNS challenge + accountEmail: acmeEmail, + certManager: this.certManager, + environment: acmeEnvironment as 'production' | 'integration', + challengeHandlers: [dns01Handler], + challengePriority: ['dns-01'], // Prefer DNS-01 challenges }); - logger.info('SSL manager initialized with SmartACME'); + // Start SmartACME + await this.smartacme.start(); + + logger.success('SSL manager initialized with SmartACME DNS-01 challenge'); } catch (error) { logger.error(`Failed to initialize SSL manager: ${error.message}`); throw error; @@ -57,59 +90,33 @@ export class OneboxSslManager { } /** - * Obtain SSL certificate for a domain + * Obtain SSL certificate for a domain using SmartACME */ - async obtainCertificate(domain: string): Promise { + async obtainCertificate(domain: string, includeWildcard = false): Promise { try { if (!this.isConfigured()) { throw new Error('SSL manager not configured'); } - logger.info(`Obtaining SSL certificate for ${domain}...`); + logger.info(`Obtaining SSL certificate for ${domain} via SmartACME DNS-01...`); // Check if certificate already exists and is valid - const existing = this.database.getSSLCertificate(domain); - if (existing && existing.expiryDate > Date.now()) { + const existingCert = await this.certManager!.retrieveCertificate(domain); + if (existingCert && existingCert.isStillValid()) { logger.info(`Valid certificate already exists for ${domain}`); return; } - // Use certbot for now (smartacme integration would be more complex) - // This is a simplified version - in production, use proper ACME client - await this.obtainCertificateWithCertbot(domain); + // Use SmartACME to obtain certificate via DNS-01 challenge + const cert = await this.smartacme!.getCertificateForDomain(domain, { + includeWildcard, + }); - // Store in database - const certPath = `/etc/letsencrypt/live/${domain}/cert.pem`; - const keyPath = `/etc/letsencrypt/live/${domain}/privkey.pem`; - const fullChainPath = `/etc/letsencrypt/live/${domain}/fullchain.pem`; - - // Get expiry date (90 days from now for Let's Encrypt) - const expiryDate = Date.now() + 90 * 24 * 60 * 60 * 1000; - - if (existing) { - this.database.updateSSLCertificate(domain, { - certPath, - keyPath, - fullChainPath, - expiryDate, - }); - } else { - await this.database.createSSLCertificate({ - domain, - certPath, - keyPath, - fullChainPath, - expiryDate, - issuer: 'Let\'s Encrypt', - createdAt: Date.now(), - updatedAt: Date.now(), - }); - } + logger.success(`SSL certificate obtained for ${domain}`); + logger.info(`Certificate valid until: ${new Date(cert.validUntil).toISOString()}`); // Reload certificates in reverse proxy await this.oneboxRef.reverseProxy.reloadCertificates(); - - logger.success(`SSL certificate obtained for ${domain}`); } catch (error) { logger.error(`Failed to obtain certificate for ${domain}: ${error.message}`); throw error; @@ -155,32 +162,21 @@ export class OneboxSslManager { } /** - * Renew certificate for a domain + * Renew certificate for a domain using SmartACME */ async renewCertificate(domain: string): Promise { try { - logger.info(`Renewing SSL certificate for ${domain}...`); - - const command = new Deno.Command('certbot', { - args: ['renew', '--cert-name', domain, '--non-interactive'], - stdout: 'piped', - stderr: 'piped', - }); - - const { code, stderr } = await command.output(); - - if (code !== 0) { - const errorMsg = new TextDecoder().decode(stderr); - throw new Error(`Certbot renewal failed: ${errorMsg}`); + if (!this.isConfigured()) { + throw new Error('SSL manager not configured'); } - // Update database - const expiryDate = Date.now() + 90 * 24 * 60 * 60 * 1000; - this.database.updateSSLCertificate(domain, { - expiryDate, - }); + logger.info(`Renewing SSL certificate for ${domain} via SmartACME...`); + + // SmartACME will check if renewal is needed and obtain a new certificate + const cert = await this.smartacme!.getCertificateForDomain(domain); logger.success(`Certificate renewed for ${domain}`); + logger.info(`Certificate valid until: ${new Date(cert.validUntil).toISOString()}`); // Reload certificates in reverse proxy await this.oneboxRef.reverseProxy.reloadCertificates(); @@ -209,21 +205,27 @@ export class OneboxSslManager { */ async renewExpiring(): Promise { try { + if (!this.isConfigured()) { + logger.warn('SSL manager not configured, skipping renewal check'); + return; + } + logger.info('Checking for expiring certificates...'); const certificates = this.listCertificates(); - const thirtyDaysFromNow = Date.now() + 30 * 24 * 60 * 60 * 1000; - for (const cert of certificates) { - if (cert.expiryDate < thirtyDaysFromNow) { - logger.info(`Certificate for ${cert.domain} expires soon, renewing...`); + for (const dbCert of certificates) { + try { + // Retrieve certificate from cert manager to check if it should be renewed + const cert = await this.certManager!.retrieveCertificate(dbCert.domain); - try { - await this.renewCertificate(cert.domain); - } catch (error) { - logger.error(`Failed to renew ${cert.domain}: ${error.message}`); - // Continue with other certificates + if (cert && cert.shouldBeRenewed()) { + logger.info(`Certificate for ${dbCert.domain} needs renewal, renewing...`); + await this.renewCertificate(dbCert.domain); } + } catch (error) { + logger.error(`Failed to renew ${dbCert.domain}: ${error.message}`); + // Continue with other certificates } } diff --git a/ts/plugins.ts b/ts/plugins.ts index 904bf6b..85e083b 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -25,9 +25,9 @@ export { smartdaemon }; import { DockerHost } from '@apiclient.xyz/docker'; export const docker = { Docker: DockerHost }; -// Cloudflare DNS Management -import Cloudflare from 'npm:cloudflare@5.2.0'; -export const cloudflare = { Cloudflare }; +// Cloudflare DNS Management (API Client) +import * as cloudflare from '@apiclient.xyz/cloudflare'; +export { cloudflare }; // Let's Encrypt / ACME import * as smartacme from '@push.rocks/smartacme'; diff --git a/ts/types.ts b/ts/types.ts index 6d9d830..9001abe 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -38,7 +38,54 @@ export interface INginxConfig { updatedAt: number; } -// SSL certificate types +// Domain management types +export interface IDomain { + id?: number; + domain: string; + dnsProvider: 'cloudflare' | 'manual' | null; + cloudflareZoneId?: string; + isObsolete: boolean; + defaultWildcard: boolean; + createdAt: number; + updatedAt: number; +} + +export interface ICertificate { + id?: number; + domainId: number; + certDomain: string; + isWildcard: boolean; + certPath: string; + keyPath: string; + fullChainPath: string; + expiryDate: number; + issuer: string; + isValid: boolean; + createdAt: number; + updatedAt: number; +} + +export interface ICertRequirement { + id?: number; + serviceId: number; + domainId: number; + subdomain: string; + certificateId?: number; + status: 'pending' | 'active' | 'renewing'; + createdAt: number; + updatedAt: number; +} + +export interface IDomainView { + domain: IDomain; + certificates: ICertificate[]; + requirements: ICertRequirement[]; + serviceCount: number; + certificateStatus: 'valid' | 'expiring-soon' | 'expired' | 'pending' | 'none'; + daysRemaining: number | null; +} + +// Legacy SSL certificate type (for backward compatibility) export interface ISslCertificate { id?: number; domain: string; diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index a3de393..35301a3 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -1,5 +1,6 @@ -import { Component } from '@angular/core'; +import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { WebSocketService } from './core/services/websocket.service'; @Component({ selector: 'app-root', @@ -7,4 +8,16 @@ import { RouterOutlet } from '@angular/router'; imports: [RouterOutlet], template: ``, }) -export class AppComponent {} +export class AppComponent implements OnInit, OnDestroy { + private wsService = inject(WebSocketService); + + ngOnInit(): void { + // Connect to WebSocket when app starts + this.wsService.connect(); + } + + ngOnDestroy(): void { + // Disconnect when app is destroyed + this.wsService.disconnect(); + } +} diff --git a/ui/src/app/core/services/api.service.ts b/ui/src/app/core/services/api.service.ts index fa2b3e0..aeb32db 100644 --- a/ui/src/app/core/services/api.service.ts +++ b/ui/src/app/core/services/api.service.ts @@ -35,9 +35,17 @@ export interface SystemStatus { running: boolean; version: any; }; - nginx: { - status: string; - installed: boolean; + reverseProxy: { + http: { + running: boolean; + port: number; + }; + https: { + running: boolean; + port: number; + certificates: number; + }; + routes: number; }; dns: { configured: boolean; diff --git a/ui/src/app/core/services/websocket.service.ts b/ui/src/app/core/services/websocket.service.ts new file mode 100644 index 0000000..0c3e045 --- /dev/null +++ b/ui/src/app/core/services/websocket.service.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@angular/core'; +import { Subject, Observable } from 'rxjs'; + +export interface WebSocketMessage { + type: string; + action?: string; + serviceName?: string; + data?: any; + status?: string; + timestamp: number; + message?: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class WebSocketService { + private ws: WebSocket | null = null; + private messageSubject = new Subject(); + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 3000; + private reconnectTimer: any = null; + + constructor() {} + + connect(): void { + if (this.ws?.readyState === WebSocket.OPEN) { + console.log('WebSocket already connected'); + return; + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/api/ws`; + + console.log('Connecting to WebSocket:', wsUrl); + + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log('✓ WebSocket connected'); + this.reconnectAttempts = 0; + }; + + this.ws.onmessage = (event) => { + try { + const message: WebSocketMessage = JSON.parse(event.data); + console.log('📨 WebSocket message:', message); + this.messageSubject.next(message); + } catch (error) { + console.error('Failed to parse WebSocket message:', error); + } + }; + + this.ws.onerror = (error) => { + console.error('✖ WebSocket error:', error); + }; + + this.ws.onclose = () => { + console.log('⚠ WebSocket closed'); + this.ws = null; + this.attemptReconnect(); + }; + } + + private attemptReconnect(): void { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('Max WebSocket reconnect attempts reached'); + return; + } + + this.reconnectAttempts++; + const delay = this.reconnectDelay * this.reconnectAttempts; + + console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); + + this.reconnectTimer = setTimeout(() => { + this.connect(); + }, delay); + } + + disconnect(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + getMessages(): Observable { + return this.messageSubject.asObservable(); + } + + isConnected(): boolean { + return this.ws?.readyState === WebSocket.OPEN; + } +} diff --git a/ui/src/app/features/dashboard/dashboard.component.ts b/ui/src/app/features/dashboard/dashboard.component.ts index 99a0f1b..7cc26e2 100644 --- a/ui/src/app/features/dashboard/dashboard.component.ts +++ b/ui/src/app/features/dashboard/dashboard.component.ts @@ -110,22 +110,26 @@ import { ApiService, SystemStatus } from '../../core/services/api.service'; - +
-

Nginx

+

Reverse Proxy

- Status - - {{ status()!.nginx.status }} + HTTP (Port {{ status()!.reverseProxy.http.port }}) + + {{ status()!.reverseProxy.http.running ? 'Running' : 'Stopped' }}
- Installed - - {{ status()!.nginx.installed ? 'Yes' : 'No' }} + HTTPS (Port {{ status()!.reverseProxy.https.port }}) + + {{ status()!.reverseProxy.https.running ? 'Running' : 'Stopped' }}
+
+ SSL Certificates + {{ status()!.reverseProxy.https.certificates }} +
diff --git a/ui/src/app/features/services/services-list.component.ts b/ui/src/app/features/services/services-list.component.ts index 554cf33..0c7e84a 100644 --- a/ui/src/app/features/services/services-list.component.ts +++ b/ui/src/app/features/services/services-list.component.ts @@ -1,7 +1,9 @@ -import { Component, OnInit, inject, signal } from '@angular/core'; +import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { ApiService, Service } from '../../core/services/api.service'; +import { WebSocketService } from '../../core/services/websocket.service'; +import { Subscription } from 'rxjs'; @Component({ selector: 'app-services-list', @@ -89,14 +91,42 @@ import { ApiService, Service } from '../../core/services/api.service'; `, }) -export class ServicesListComponent implements OnInit { +export class ServicesListComponent implements OnInit, OnDestroy { private apiService = inject(ApiService); + private wsService = inject(WebSocketService); + private wsSubscription?: Subscription; services = signal([]); loading = signal(true); ngOnInit(): void { + // Initial load this.loadServices(); + + // Subscribe to WebSocket updates + this.wsSubscription = this.wsService.getMessages().subscribe((message) => { + this.handleWebSocketMessage(message); + }); + } + + ngOnDestroy(): void { + this.wsSubscription?.unsubscribe(); + } + + private handleWebSocketMessage(message: any): void { + if (message.type === 'service_update') { + // Reload the full service list on any service update + this.loadServices(); + } else if (message.type === 'service_status') { + // Update individual service status + const currentServices = this.services(); + const updatedServices = currentServices.map(s => + s.name === message.serviceName + ? { ...s, status: message.status } + : s + ); + this.services.set(updatedServices); + } } loadServices(): void { @@ -117,7 +147,7 @@ export class ServicesListComponent implements OnInit { startService(service: Service): void { this.apiService.startService(service.name).subscribe({ next: () => { - this.loadServices(); + // WebSocket will handle the update }, }); } @@ -125,7 +155,7 @@ export class ServicesListComponent implements OnInit { stopService(service: Service): void { this.apiService.stopService(service.name).subscribe({ next: () => { - this.loadServices(); + // WebSocket will handle the update }, }); } @@ -133,7 +163,7 @@ export class ServicesListComponent implements OnInit { restartService(service: Service): void { this.apiService.restartService(service.name).subscribe({ next: () => { - this.loadServices(); + // WebSocket will handle the update }, }); } @@ -142,7 +172,7 @@ export class ServicesListComponent implements OnInit { if (confirm(`Are you sure you want to delete ${service.name}?`)) { this.apiService.deleteService(service.name).subscribe({ next: () => { - this.loadServices(); + // WebSocket will handle the update }, }); }