diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 086c1df..b4c8ceb 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -51,6 +51,8 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Set lowercase owner + run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - uses: docker/setup-buildx-action@v3 - uses: docker/build-push-action@v6 with: @@ -58,5 +60,5 @@ jobs: context: ${{ matrix.context }} file: ${{ matrix.dockerfile }} tags: | - ghcr.io/${{ github.repository_owner }}/${{ matrix.image_suffix }}:latest - ghcr.io/${{ github.repository_owner }}/${{ matrix.image_suffix }}:${{ steps.package_version.outputs.VERSION }} \ No newline at end of file + ghcr.io/${{ env.OWNER }}/${{ matrix.image_suffix }}:latest + ghcr.io/${{ env.OWNER }}/${{ matrix.image_suffix }}:${{ steps.package_version.outputs.VERSION }} \ No newline at end of file diff --git a/dashboard/.gitignore b/dashboard/.gitignore index 3b462cb..3635a66 100644 --- a/dashboard/.gitignore +++ b/dashboard/.gitignore @@ -1,4 +1,5 @@ node_modules +package-lock.json # Output .output @@ -7,6 +8,7 @@ node_modules .wrangler /.svelte-kit /build +/dist # OS .DS_Store diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile index db386c5..2ab1951 100644 --- a/dashboard/Dockerfile +++ b/dashboard/Dockerfile @@ -17,8 +17,12 @@ FROM base AS build RUN pnpm install --frozen-lockfile COPY ./src ./src +COPY ./server.ts ./server.ts +COPY ./ws-proxy.ts ./ws-proxy.ts +COPY ./auth-check.ts ./auth-check.ts +COPY ./tsconfig.server.json ./tsconfig.server.json -RUN pnpm build +RUN pnpm build && pnpm tsc -p tsconfig.server.json FROM base AS prod-deps @@ -28,7 +32,9 @@ FROM node:25-alpine WORKDIR /app +COPY --from=build /app/package.json ./package.json COPY --from=prod-deps /app/node_modules ./node_modules -COPY --from=build /app/build ./build +COPY --from=build /app/build ./build +COPY --from=build /app/dist ./dist -CMD ["node", "build/index.js"] \ No newline at end of file +CMD ["node", "dist/server.js"] \ No newline at end of file diff --git a/dashboard/auth-check.ts b/dashboard/auth-check.ts new file mode 100644 index 0000000..6eb776c --- /dev/null +++ b/dashboard/auth-check.ts @@ -0,0 +1,38 @@ +import crypto from 'node:crypto'; + +export const SESSION_COOKIE_NAME = 'SESSION_ID'; + +export function hashAuthKey(value: string): string { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +export function isAuthRequired(): boolean { + return !!process.env.AUTH_KEY; +} + +export function expectedHashedAuthKey(): string { + return hashAuthKey(process.env.AUTH_KEY!); +} + +export function parseCookies(cookieStr: string): Record { + const cookies: Record = {}; + cookieStr.split(';').forEach(cookie => { + const [name, ...rest] = cookie.trim().split('='); + if (name) cookies[name] = rest.join('='); + }); + return cookies; +} + +export function isSessionValid(cookieValue: string | undefined): boolean { + if (!isAuthRequired()) return true; + return cookieValue === expectedHashedAuthKey(); +} + +/** + * Check if a raw HTTP cookie header represents an authenticated session. + * Returns true if AUTH_KEY is not set (no auth required). + */ +export function isAuthenticatedByCookie(cookieHeader: string | undefined): boolean { + const cookies = parseCookies(cookieHeader || ''); + return isSessionValid(cookies[SESSION_COOKIE_NAME]); +} diff --git a/dashboard/package.json b/dashboard/package.json index eb4cb08..04b699a 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -19,6 +19,7 @@ "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.1.18", "@types/luxon": "^3.7.1", + "@types/node": "^25.3.3", "bits-ui": "^2.14.4", "clsx": "^2.1.1", "svelte": "^5.48.2", diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index fa9d18f..501a227 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -26,22 +26,25 @@ importers: version: 0.561.0(svelte@5.49.1) '@sveltejs/adapter-node': specifier: ^5.5.2 - version: 5.5.2(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2))) + version: 5.5.2(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2))) '@sveltejs/kit': specifier: ^2.50.1 - version: 2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)) + version: 2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)) '@sveltejs/vite-plugin-svelte': specifier: ^6.2.4 - version: 6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)) + version: 6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)) '@tailwindcss/vite': specifier: ^4.1.18 - version: 4.1.18(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)) + version: 4.1.18(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)) '@types/luxon': specifier: ^3.7.1 version: 3.7.1 + '@types/node': + specifier: ^25.3.3 + version: 25.3.3 bits-ui: specifier: ^2.14.4 - version: 2.15.5(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1) + version: 2.15.5(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -68,7 +71,7 @@ importers: version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(jiti@2.6.1)(lightningcss@1.30.2) + version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2) packages: @@ -337,66 +340,79 @@ packages: resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.0': resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.0': resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.0': resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.0': resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.0': resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.0': resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.0': resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.0': resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.0': resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.0': resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.0': resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.0': resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.0': resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==} @@ -513,24 +529,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -574,6 +594,9 @@ packages: '@types/luxon@3.7.1': resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/node@25.3.3': + resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -728,24 +751,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -933,6 +960,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1223,19 +1253,19 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-node@5.5.2(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))': + '@sveltejs/adapter-node@5.5.2(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))': dependencies: '@rollup/plugin-commonjs': 28.0.9(rollup@4.57.0) '@rollup/plugin-json': 6.1.0(rollup@4.57.0) '@rollup/plugin-node-resolve': 16.0.3(rollup@4.57.0) - '@sveltejs/kit': 2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)) + '@sveltejs/kit': 2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)) rollup: 4.57.0 - '@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2))': + '@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -1248,26 +1278,26 @@ snapshots: set-cookie-parser: 2.7.2 sirv: 3.0.2 svelte: 5.49.1 - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2) optionalDependencies: typescript: 5.9.3 - '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)) obug: 2.1.1 svelte: 5.49.1 - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2) - '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2))': + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)) deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 svelte: 5.49.1 - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.30.2) - vitefu: 1.1.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2) + vitefu: 1.1.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)) '@swc/helpers@0.5.18': dependencies: @@ -1334,12 +1364,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.18(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2))': + '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2) '@types/cookie@0.6.0': {} @@ -1347,6 +1377,10 @@ snapshots: '@types/luxon@3.7.1': {} + '@types/node@25.3.3': + dependencies: + undici-types: 7.18.2 + '@types/resolve@1.20.2': {} acorn@8.15.0: {} @@ -1355,15 +1389,15 @@ snapshots: axobject-query@4.1.0: {} - bits-ui@2.15.5(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1): + bits-ui@2.15.5(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1): dependencies: '@floating-ui/core': 1.7.4 '@floating-ui/dom': 1.7.5 '@internationalized/date': 3.10.1 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1) + runed: 0.35.1(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1) svelte: 5.49.1 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -1597,14 +1631,14 @@ snapshots: esm-env: 1.2.2 svelte: 5.49.1 - runed@0.35.1(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1): + runed@0.35.1(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.49.1 optionalDependencies: - '@sveltejs/kit': 2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)) + '@sveltejs/kit': 2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)) sade@1.8.1: dependencies: @@ -1638,10 +1672,10 @@ snapshots: transitivePeerDependencies: - picomatch - svelte-toolbelt@0.10.6(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1) + runed: 0.35.1(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.1)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.49.1) style-to-object: 1.0.14 svelte: 5.49.1 transitivePeerDependencies: @@ -1699,7 +1733,9 @@ snapshots: typescript@5.9.3: {} - vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2): + undici-types@7.18.2: {} + + vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -1708,12 +1744,13 @@ snapshots: rollup: 4.57.0 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 25.3.3 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 - vitefu@1.1.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)): + vitefu@1.1.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)): optionalDependencies: - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2) zimmerframe@1.1.4: {} diff --git a/dashboard/server.ts b/dashboard/server.ts new file mode 100644 index 0000000..95cd8f1 --- /dev/null +++ b/dashboard/server.ts @@ -0,0 +1,8 @@ +import http from 'node:http'; +import { setupWebSocketProxy } from './ws-proxy.ts'; + +// SvelteKit adapter-node build output +// @ts-ignore - build output only exists after `pnpm build` +const { server } = await import('../build/index.js') as { server: { server: http.Server } }; + +setupWebSocketProxy(server.server); diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 7213131..2c0540f 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -1,13 +1,3 @@ -const blitzbrowser_api: { url: string; api_key: string | undefined } = (await (await fetch('/api')).json()); - -const is_authentication_required = typeof blitzbrowser_api.api_key === 'string'; - -const api_key_header: { [key: string]: string } = blitzbrowser_api.api_key ? { 'x-api-key': blitzbrowser_api.api_key } : {}; -const api_key_param = blitzbrowser_api.api_key ? `apiKey=${blitzbrowser_api.api_key}` : ''; - -const api_url = new URL(blitzbrowser_api.url); -const websocket_url = new URL(`${api_url.protocol === 'https' ? 'wss' : 'ws'}://${api_url.host}`); - export interface BrowserPool { id: string; started_at: string; @@ -15,20 +5,40 @@ export interface BrowserPool { tags: { [key: string]: string; }; }; -export async function getBrowserPool(): Promise { - const response = await fetch(`${api_url}browser-pool`, { - headers: { - ...api_key_header - } - }); +const apiConfig: { proxy: boolean; url?: string; api_key?: string } = + (await (await fetch('/api')).json()); + +// Pre-compute direct-mode values when not proxying +const api_url = apiConfig.proxy ? null : new URL(apiConfig.url!); +const websocket_url = apiConfig.proxy ? null : new URL( + `${api_url!.protocol === 'https:' ? 'wss' : 'ws'}://${api_url!.host}` +); +const api_key_header: { [key: string]: string } = + (!apiConfig.proxy && apiConfig.api_key) ? { 'x-api-key': apiConfig.api_key } : {}; +const api_key_param = (!apiConfig.proxy && apiConfig.api_key) ? `apiKey=${apiConfig.api_key}` : ''; +const is_authentication_required = !apiConfig.proxy && typeof apiConfig.api_key === 'string'; +export async function getBrowserPool(): Promise { + if (apiConfig.proxy) { + const response = await fetch('/api/browser-pool'); + return await response.json(); + } + const response = await fetch(`${api_url}browser-pool`, { headers: { ...api_key_header } }); return await response.json(); } export function getBrowserInstancesEventsWebsocketURL() { + if (apiConfig.proxy) { + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${location.host}/api/ws/browser-instances`; + } return `${websocket_url}browser-instances${is_authentication_required ? `?${api_key_param}` : ''}`; } export function getLiveViewWebsocketUrl(browser_instance_id: string) { + if (apiConfig.proxy) { + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${location.host}/api/ws/browser-instances/${browser_instance_id}/vnc`; + } return `${websocket_url}browser-instances/${browser_instance_id}/vnc${is_authentication_required ? `?${api_key_param}` : ''}`; -} \ No newline at end of file +} diff --git a/dashboard/src/lib/auth.ts b/dashboard/src/lib/auth.ts index 352f34f..50fb616 100644 --- a/dashboard/src/lib/auth.ts +++ b/dashboard/src/lib/auth.ts @@ -1,24 +1,19 @@ import type { Cookies } from "@sveltejs/kit"; import { env } from '$env/dynamic/private'; - -const COOKIE_SESSION_ID = 'SESSION_ID'; +import { SESSION_COOKIE_NAME, hashAuthKey, expectedHashedAuthKey, isAuthRequired, isSessionValid } from '../../auth-check.ts'; const is_https_required = env.HTTPS_DISABLED !== 'true'; -const is_authentication_required = typeof env.AUTH_KEY === 'string'; - -const auth_key_sha256 = is_authentication_required ? await digest(env.AUTH_KEY) : undefined; - export function isAuthenticated(cookies: Cookies) { - return !is_authentication_required || cookies.get(COOKIE_SESSION_ID) === auth_key_sha256; + return isSessionValid(cookies.get(SESSION_COOKIE_NAME)); } export async function authenticate(auth_key: string, cookies: Cookies) { - if (auth_key_sha256 !== await digest(auth_key)) { + if (!isAuthRequired() || hashAuthKey(auth_key) !== expectedHashedAuthKey()) { return false; } - cookies.set(COOKIE_SESSION_ID, auth_key_sha256, { + cookies.set(SESSION_COOKIE_NAME, expectedHashedAuthKey(), { path: '/', httpOnly: true, secure: is_https_required @@ -26,9 +21,3 @@ export async function authenticate(auth_key: string, cookies: Cookies) { return true; } - -async function digest(value: string) { - const array_buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); - - return Array.from(new Uint8Array(array_buffer)).map(b => b.toString(16).padStart(2, '0')).join(''); -} \ No newline at end of file diff --git a/dashboard/src/lib/browsers.svelte.ts b/dashboard/src/lib/browsers.svelte.ts index 061dcd2..7f46e8c 100644 --- a/dashboard/src/lib/browsers.svelte.ts +++ b/dashboard/src/lib/browsers.svelte.ts @@ -24,20 +24,31 @@ export class BrowserStore { browsers: Map = $state(new Map()); + connection_error: string | undefined = $state(undefined); + #websocket: WebSocket | undefined; + #ws_consecutive_failures = 0; + + static readonly MAX_RETRIES = 3; + /** * Connect the client to the backend. Update the browser pool status and connect to the browser instances status feed. */ async connect() { - await this.#updateBrowserPool(); - this.#connectBrowserInstances(); + const ok = await this.#updateBrowserPool(); + if (ok) { + this.#connectBrowserInstances(); + } } #connectBrowserInstances() { this.#websocket = new WebSocket(getBrowserInstancesEventsWebsocketURL()); this.#websocket.onmessage = (event) => { + this.#ws_consecutive_failures = 0; + this.connection_error = undefined; + const browser_instances = JSON.parse(event.data) as BrowserInstances[]; this.browsers = browser_instances.reduce((map, browser) => { @@ -47,6 +58,13 @@ export class BrowserStore { }; this.#websocket.onclose = () => { + this.#ws_consecutive_failures++; + + if (this.#ws_consecutive_failures >= BrowserStore.MAX_RETRIES) { + this.connection_error = 'Unable to connect to BlitzBrowser'; + return; + } + setTimeout(() => { this.#connectBrowserInstances(); }, 250); @@ -54,17 +72,18 @@ export class BrowserStore { } async #updateBrowserPool() { - while (true) { + for (let attempt = 0; attempt < BrowserStore.MAX_RETRIES; attempt++) { try { - browser_store.browser_pool = await getBrowserPool(); - - return; - } catch (e) { - await new Promise((resolve) => { - setTimeout(resolve, 100); - }) + this.browser_pool = await getBrowserPool(); + this.connection_error = undefined; + return true; + } catch { + await new Promise((resolve) => setTimeout(resolve, 500)); } } + + this.connection_error = 'Unable to connect to BlitzBrowser'; + return false; } } diff --git a/dashboard/src/routes/(dashboard)/+layout.svelte b/dashboard/src/routes/(dashboard)/+layout.svelte index f8e1aa0..e8b9094 100644 --- a/dashboard/src/routes/(dashboard)/+layout.svelte +++ b/dashboard/src/routes/(dashboard)/+layout.svelte @@ -4,10 +4,12 @@ import AppSidebar from "$lib/components/app-sidebar.svelte"; import { Separator } from "$lib/components/ui/separator/index.js"; import * as Sidebar from "$lib/components/ui/sidebar/index.js"; + import * as Alert from "$lib/components/ui/alert/index.js"; import favicon from "$lib/assets/favicon.ico"; import { onMount } from "svelte"; import { browser_store } from "$lib/browsers.svelte"; import Breadcrumbs from "$lib/components/breadcrumbs.svelte"; + import CircleAlert from "@lucide/svelte/icons/circle-alert"; let { children } = $props(); @@ -38,6 +40,15 @@
+ {#if browser_store.connection_error} + + + {browser_store.connection_error} + + Check that BlitzBrowser is running and the dashboard is configured correctly. + + + {/if} {@render children()}
diff --git a/dashboard/src/routes/api/+server.ts b/dashboard/src/routes/api/+server.ts index 8f5a8dd..342dc8f 100644 --- a/dashboard/src/routes/api/+server.ts +++ b/dashboard/src/routes/api/+server.ts @@ -7,8 +7,13 @@ export function GET({ cookies }) { error(403, 'You are not authenticated'); } + if (env.PROXY_BLITZBROWSER_API === 'true') { + return json({ proxy: true }); + } + return json({ + proxy: false, url: env.BLITZBROWSER_API_URL || 'http://localhost:9999', api_key: env.BLITZBROWSER_API_KEY }); -} \ No newline at end of file +} diff --git a/dashboard/src/routes/api/browser-pool/+server.ts b/dashboard/src/routes/api/browser-pool/+server.ts new file mode 100644 index 0000000..ac0d4a2 --- /dev/null +++ b/dashboard/src/routes/api/browser-pool/+server.ts @@ -0,0 +1,21 @@ +import { env } from '$env/dynamic/private'; +import { isAuthenticated } from '$lib/auth.js'; +import { error, json } from '@sveltejs/kit'; + +export async function GET({ cookies }) { + if (!isAuthenticated(cookies)) { + error(403, 'You are not authenticated'); + } + + const api_url = (env.BLITZBROWSER_API_URL || 'http://localhost:9999').replace(/\/$/, ''); + const headers: Record = {}; + if (env.BLITZBROWSER_API_KEY) { + headers['x-api-key'] = env.BLITZBROWSER_API_KEY; + } + + const response = await fetch(`${api_url}/browser-pool`, { headers }); + if (!response.ok) { + error(response.status, await response.text()); + } + return json(await response.json()); +} diff --git a/dashboard/tsconfig.server.json b/dashboard/tsconfig.server.json new file mode 100644 index 0000000..78142e5 --- /dev/null +++ b/dashboard/tsconfig.server.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "rewriteRelativeImportExtensions": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": ["server.ts", "ws-proxy.ts", "auth-check.ts"] +} diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index f3c6fee..887df3d 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -1,7 +1,19 @@ import tailwindcss from '@tailwindcss/vite'; import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; +import { defineConfig, type Plugin } from 'vite'; +import { setupWebSocketProxy } from './ws-proxy.ts'; -export default defineConfig({ - plugins: [tailwindcss(), sveltekit()] +function wsProxy(): Plugin { + return { + name: 'ws-proxy', + configureServer(server) { + if (server.httpServer) { + setupWebSocketProxy(server.httpServer); + } + } + }; +} + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit(), wsProxy()] }); diff --git a/dashboard/ws-proxy.ts b/dashboard/ws-proxy.ts new file mode 100644 index 0000000..47e217c --- /dev/null +++ b/dashboard/ws-proxy.ts @@ -0,0 +1,91 @@ +import http from 'node:http'; +import https from 'node:https'; +import type { Duplex } from 'node:stream'; +import type { EventEmitter } from 'node:events'; +import { isAuthenticatedByCookie } from './auth-check.ts'; + +export function setupWebSocketProxy(httpServer: EventEmitter): void { + const apiUrl = process.env.BLITZBROWSER_API_URL || 'http://localhost:9999'; + const apiKey = process.env.BLITZBROWSER_API_KEY; + + httpServer.on('upgrade', (req: http.IncomingMessage, socket: Duplex, head: Buffer) => { + const url = req.url || ''; + if (!url.startsWith('/api/ws/')) { + return; + } + + // Prevent unhandled socket errors from crashing the process + socket.on('error', () => socket.destroy()); + + // Authenticate via session cookie + if (!isAuthenticatedByCookie(req.headers.cookie)) { + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.destroy(); + return; + } + + // Strip /api/ws prefix to get the backend path + const backendPath = url.replace(/^\/api\/ws/, ''); + + // Add API key as query parameter + const separator = backendPath.includes('?') ? '&' : '?'; + const targetPath = apiKey + ? `${backendPath}${separator}apiKey=${encodeURIComponent(apiKey)}` + : backendPath; + + const target = new URL(apiUrl); + const transport = target.protocol === 'https:' ? https : http; + + const proxyReq = transport.request({ + hostname: target.hostname, + port: target.port || (target.protocol === 'https:' ? 443 : 80), + path: targetPath, + method: 'GET', + headers: { + ...req.headers, + host: target.host, + }, + }); + + proxyReq.on('upgrade', (proxyRes: http.IncomingMessage, proxySocket: Duplex, proxyHead: Buffer) => { + let response = 'HTTP/1.1 101 Switching Protocols\r\n'; + for (const [key, value] of Object.entries(proxyRes.headers)) { + if (value !== undefined) { + const values = Array.isArray(value) ? value : [value]; + for (const v of values) { + response += `${key}: ${v}\r\n`; + } + } + } + response += '\r\n'; + + socket.write(response); + + // Forward any buffered data + if (proxyHead.length > 0) socket.write(proxyHead); + if (head.length > 0) proxySocket.write(head); + + // Pipe bidirectionally + proxySocket.pipe(socket); + socket.pipe(proxySocket); + + proxySocket.on('error', () => socket.destroy()); + socket.on('error', () => proxySocket.destroy()); + }); + + proxyReq.on('response', (proxyRes: http.IncomingMessage) => { + // Backend rejected the upgrade - forward the error + const statusCode = proxyRes.statusCode || 502; + const statusMessage = proxyRes.statusMessage || 'Bad Gateway'; + socket.write(`HTTP/1.1 ${statusCode} ${statusMessage}\r\n\r\n`); + socket.destroy(); + }); + + proxyReq.on('error', () => { + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); + socket.destroy(); + }); + + proxyReq.end(); + }); +} diff --git a/docker-compose.yml b/docker-compose.yml index 08bef06..115b637 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,9 @@ services: - "3000:3000" environment: AUTH_KEY: 123 + BLITZBROWSER_API_URL: http://blitzbrowser:9999 BLITZBROWSER_API_KEY: asd + PROXY_BLITZBROWSER_API: true restart: always s3: image: rustfs/rustfs @@ -53,4 +55,4 @@ services: " restart: "no" volumes: - s3_data: \ No newline at end of file + s3_data: