diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e445aca..8c28701 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ -alanfrigo \ No newline at end of file +github: alanfrigo +buy_me_a_coffee: alanfrigo \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6eb4536..ba588cd 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,8 @@ next-env.d.ts # Traefik ACME certificates (production) /traefik/acme +# Traefik dynamic configs (auto-generated) +/traefik/dynamic/*.yml .env .env.local \ No newline at end of file diff --git a/README.md b/README.md index d223d67..3a78783 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,15 @@ -# SupaPanel +
+ SupaPanel Logo +

SupaPanel

+

Open-source management panel for self-hosted Supabase instances

-**Open-source management panel for self-hosted Supabase instances** + [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://hub.docker.com) + [![Next.js](https://img.shields.io/badge/Next.js-15-black.svg)](https://nextjs.org) -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://hub.docker.com) -[![Next.js](https://img.shields.io/badge/Next.js-15-black.svg)](https://nextjs.org) +
+ SupaPanel Demo +
SupaPanel is a web-based control panel that simplifies the deployment and management of multiple self-hosted Supabase projects. Deploy your own Supabase infrastructure on any Linux server with a single command. @@ -131,10 +136,63 @@ npm run dev ### Custom Domain Configuration -1. Point your domain DNS A record to the server IP -2. Navigate to project settings > Domain +To use a custom domain for your Supabase project, follow these steps: + +#### Prerequisites + +- A domain you own with access to DNS settings +- Your SupaPanel server's public IP address + +#### Step 1: Configure DNS Records + +Add the following DNS records pointing to your server's IP: + +| Type | Name | Value | +|------|------|-------| +| A | `api.example.com` | `YOUR_SERVER_IP` | +| A | `studio.api.example.com` | `YOUR_SERVER_IP` | + +> **Note:** Replace `api.example.com` with your chosen domain and `YOUR_SERVER_IP` with your server's public IP address. + +#### Step 2: Configure Domain in SupaPanel + +1. Go to **Dashboard** → Select your project → Click **Configure** +2. Find the **Custom Domain Configuration** section at the top 3. Enter your domain (e.g., `api.example.com`) -4. Traefik automatically provisions SSL certificates +4. Click **Save Domain** + +#### Step 3: Automatic SSL Provisioning + +Once DNS is configured: +- Traefik automatically detects the domain +- Let's Encrypt issues an SSL certificate +- HTTPS is enabled automatically (no manual configuration needed) + +#### Resulting URLs + +After configuration, your Supabase services will be available at: + +| Service | URL | +|---------|-----| +| **Supabase API** | `https://api.example.com` | +| **Supabase Studio** | `https://studio.api.example.com` | +| **PostgREST** | `https://api.example.com/rest/v1` | +| **Auth** | `https://api.example.com/auth/v1` | +| **Storage** | `https://api.example.com/storage/v1` | +| **Realtime** | `wss://api.example.com/realtime/v1` | + +### Panel Domain Configuration + +You can also set up a custom domain for the SupaPanel dashboard itself: + +1. Go to **Dashboard** → Click **Settings** (gear icon in header) +2. In the **Panel Domain** section, enter your domain (e.g., `panel.example.com`) +3. Point your domain's DNS A record to this server's IP address +4. Click **Save Domain** + +After configuration: +- Access your SupaPanel at `https://panel.example.com` +- Direct IP access (`http://YOUR_IP:3000`) remains available as fallback --- diff --git a/package-lock.json b/package-lock.json index 9750f63..6850694 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "supapanel", "version": "0.1.0", "dependencies": { - "@prisma/client": "^6.15.0", + "@prisma/client": "^6.19.1", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.3", "@types/bcryptjs": "^2.4.6", @@ -20,7 +20,6 @@ "next": "^15.5.9", "next-auth": "^4.24.11", "nodemailer": "^7.0.11", - "prisma": "^6.15.0", "react": "19.2.3", "react-dom": "19.2.3", "tailwind-merge": "^3.3.1", @@ -35,6 +34,7 @@ "eslint": "^9", "eslint-config-next": "16.0.10", "postcss": "^8.5.6", + "prisma": "^6.19.1", "tailwindcss": "^3.4.15", "tw-animate-css": "^1.3.7", "typescript": "^5" @@ -790,7 +790,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2037,6 +2036,7 @@ "version": "6.19.1", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.1.tgz", "integrity": "sha512-bUL/aYkGXLwxVGhJmQMtslLT7KPEfUqmRa919fKI4wQFX4bIFUKiY8Jmio/2waAjjPYrtuDHa7EsNCnJTXxiOw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", @@ -2049,12 +2049,14 @@ "version": "6.19.1", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.1.tgz", "integrity": "sha512-h1JImhlAd/s5nhY/e9qkAzausWldbeT+e4nZF7A4zjDYBF4BZmKDt4y0jK7EZapqOm1kW7V0e9agV/iFDy3fWw==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "6.19.1", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.1.tgz", "integrity": "sha512-xy95dNJ7DiPf9IJ3oaVfX785nbFl7oNDzclUF+DIiJw6WdWCvPl0LPU0YqQLsrwv8N64uOQkH391ujo3wSo+Nw==", + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -2068,12 +2070,14 @@ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "6.19.1", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.1.tgz", "integrity": "sha512-mmgcotdaq4VtAHO6keov3db+hqlBzQS6X7tR7dFCbvXjLVTxBYdSJFRWz+dq7F9p6dvWyy1X0v8BlfRixyQK6g==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.1", @@ -2085,6 +2089,7 @@ "version": "6.19.1", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.1.tgz", "integrity": "sha512-zsg44QUiQAnFUyh6Fbt7c9HjMXHwFTqtrgcX7DAZmRgnkPyYT7Sh8Mn8D5PuuDYNtMOYcpLGg576MLfIORsBYw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.1" @@ -2760,6 +2765,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, "license": "MIT" }, "node_modules/@swc/helpers": { @@ -2834,7 +2840,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2845,7 +2850,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2895,7 +2899,6 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -3382,7 +3385,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3812,7 +3814,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3831,6 +3832,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", @@ -3965,6 +3967,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -3980,6 +3983,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -4052,12 +4056,14 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "devOptional": true, "license": "MIT" }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -4203,6 +4209,7 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -4248,12 +4255,14 @@ "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "devOptional": true, "license": "MIT" }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -4295,6 +4304,7 @@ "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -4322,6 +4332,7 @@ "version": "3.18.4", "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -4346,6 +4357,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=14" @@ -4557,7 +4569,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4743,7 +4754,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5001,12 +5011,14 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, "license": "MIT" }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, "funding": [ { "type": "individual", @@ -5344,6 +5356,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -6020,6 +6033,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -6466,6 +6480,7 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, "license": "MIT" }, "node_modules/node-releases": { @@ -6480,7 +6495,6 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", "license": "MIT-0", - "peer": true, "engines": { "node": ">=6.0.0" } @@ -6498,6 +6512,7 @@ "version": "0.6.2", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", + "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -6654,6 +6669,7 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, "license": "MIT" }, "node_modules/oidc-token-hash": { @@ -6791,12 +6807,14 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, "license": "MIT" }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, "license": "MIT" }, "node_modules/picocolors": { @@ -6839,6 +6857,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, "license": "MIT", "dependencies": { "confbox": "^0.2.2", @@ -6875,7 +6894,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7018,7 +7036,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.0.tgz", "integrity": "sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -7056,9 +7073,9 @@ "version": "6.19.1", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.1.tgz", "integrity": "sha512-XRfmGzh6gtkc/Vq3LqZJcS2884dQQW3UhPo6jNRoiTW95FFQkXFg8vkYEy6og+Pyv0aY7zRQ7Wn1Cvr56XjhQQ==", + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "6.19.1", "@prisma/engines": "6.19.1" @@ -7104,6 +7121,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, "funding": [ { "type": "individual", @@ -7140,6 +7158,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -7151,7 +7170,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7161,7 +7179,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7189,6 +7206,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -7845,7 +7863,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -8006,6 +8023,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -8049,7 +8067,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8214,7 +8231,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8503,7 +8519,6 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 867e837..83091b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "supapanel", "version": "0.1.0", + "description": "Open-source management panel for self-hosted Supabase instances", "private": true, "scripts": { "dev": "next dev --turbopack", @@ -14,7 +15,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@prisma/client": "^6.15.0", + "@prisma/client": "^6.19.1", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.3", "@types/bcryptjs": "^2.4.6", @@ -26,7 +27,6 @@ "next": "^15.5.9", "next-auth": "^4.24.11", "nodemailer": "^7.0.11", - "prisma": "^6.15.0", "react": "19.2.3", "react-dom": "19.2.3", "tailwind-merge": "^3.3.1", @@ -41,6 +41,7 @@ "eslint": "^9", "eslint-config-next": "16.0.10", "postcss": "^8.5.6", + "prisma": "^6.19.1", "tailwindcss": "^3.4.15", "tw-animate-css": "^1.3.7", "typescript": "^5" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4956cfd..f716c07 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -45,8 +45,10 @@ model Project { slug String @unique description String? status String @default("active") // active, paused, stopped - domain String? // Custom domain for this project - domainVerified Boolean @default(false) // Whether the domain is verified + domain String? // Custom domain for API (Kong) + domainVerified Boolean @default(false) // Whether the API domain is verified + studioDomain String? // Custom domain for Supabase Studio + studioDomainVerified Boolean @default(false) // Whether the Studio domain is verified createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -97,3 +99,13 @@ model PasswordResetToken { @@map("password_reset_tokens") } + +model PanelSettings { + id String @id @default(uuid()) + key String @unique + value String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("panel_settings") +} diff --git a/public/demo.png b/public/demo.png index 04557a2..9be899c 100644 Binary files a/public/demo.png and b/public/demo.png differ diff --git a/public/icon.png b/public/icon.png index a0eae32..6d77eb0 100644 Binary files a/public/icon.png and b/public/icon.png differ diff --git a/public/logo.png b/public/logo.png index 17b45d3..ffc162c 100644 Binary files a/public/logo.png and b/public/logo.png differ diff --git a/src/app/api/projects/[id]/domain/route.ts b/src/app/api/projects/[id]/domain/route.ts index 0c1a5bd..2bc1527 100644 --- a/src/app/api/projects/[id]/domain/route.ts +++ b/src/app/api/projects/[id]/domain/route.ts @@ -6,7 +6,7 @@ import { generateProjectTraefikConfig, verifyDomainDNS, getProjectPorts } from ' /** * GET /api/projects/[id]/domain - * Get the domain configuration for a project + * Get the domain configuration for a project (API + Studio domains) */ export async function GET( request: NextRequest, @@ -32,6 +32,8 @@ export async function GET( id: true, domain: true, domainVerified: true, + studioDomain: true, + studioDomainVerified: true, }, }) @@ -42,6 +44,8 @@ export async function GET( return NextResponse.json({ domain: project.domain, verified: project.domainVerified, + studioDomain: project.studioDomain, + studioVerified: project.studioDomainVerified, }) } catch (error) { console.error('Get domain error:', error) @@ -51,7 +55,7 @@ export async function GET( /** * PUT /api/projects/[id]/domain - * Set or update the custom domain for a project + * Set or update the custom domains for a project (API and/or Studio) */ export async function PUT( request: NextRequest, @@ -70,16 +74,21 @@ export async function PUT( } const { id } = await params - const { domain } = await request.json() - - if (!domain) { - return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) - } + const { domain, studioDomain } = await request.json() // Validate domain format const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-_.]*\.[a-zA-Z]{2,}$/ - if (!domainRegex.test(domain)) { - return NextResponse.json({ error: 'Invalid domain format' }, { status: 400 }) + + if (domain && !domainRegex.test(domain)) { + return NextResponse.json({ error: 'Invalid API domain format' }, { status: 400 }) + } + + if (studioDomain && !domainRegex.test(studioDomain)) { + return NextResponse.json({ error: 'Invalid Studio domain format' }, { status: 400 }) + } + + if (!domain && !studioDomain) { + return NextResponse.json({ error: 'At least one domain is required' }, { status: 400 }) } // Check if project exists @@ -94,28 +103,51 @@ export async function PUT( return NextResponse.json({ error: 'Project not found' }, { status: 404 }) } - // Check if domain is already in use by another project - const existingProject = await prisma.project.findFirst({ - where: { - domain, - id: { not: id }, - }, - }) + // Check if API domain is already in use by another project + if (domain) { + const existingApiProject = await prisma.project.findFirst({ + where: { + domain, + id: { not: id }, + }, + }) + if (existingApiProject) { + return NextResponse.json({ error: 'API domain is already in use' }, { status: 409 }) + } + } - if (existingProject) { - return NextResponse.json({ error: 'Domain is already in use' }, { status: 409 }) + // Check if Studio domain is already in use by another project + if (studioDomain) { + const existingStudioProject = await prisma.project.findFirst({ + where: { + studioDomain, + id: { not: id }, + }, + }) + if (existingStudioProject) { + return NextResponse.json({ error: 'Studio domain is already in use' }, { status: 409 }) + } } - // Verify domain DNS points to this server - const dnsValid = await verifyDomainDNS(domain) + // Verify domain DNS + const apiDnsValid = domain ? await verifyDomainDNS(domain) : false + const studioDnsValid = studioDomain ? await verifyDomainDNS(studioDomain) : false - // Update the project's domain + // Build update data + const updateData: Record = {} + if (domain !== undefined) { + updateData.domain = domain || null + updateData.domainVerified = domain ? apiDnsValid : false + } + if (studioDomain !== undefined) { + updateData.studioDomain = studioDomain || null + updateData.studioDomainVerified = studioDomain ? studioDnsValid : false + } + + // Update the project's domains const updatedProject = await prisma.project.update({ where: { id }, - data: { - domain, - domainVerified: dnsValid, - }, + data: updateData, }) // Generate Traefik configuration for this project @@ -125,20 +157,40 @@ export async function PUT( }) const ports = getProjectPorts(envVarsMap) - await generateProjectTraefikConfig({ - projectSlug: project.slug, - domain, - kongPort: ports.kongPort, - studioPort: ports.studioPort, - }) + // Only generate Traefik config if we have at least one domain + const finalDomain = domain || updatedProject.domain + const finalStudioDomain = studioDomain || updatedProject.studioDomain + + if (finalDomain || finalStudioDomain) { + await generateProjectTraefikConfig({ + projectSlug: project.slug, + domain: finalDomain || '', + studioDomain: finalStudioDomain || '', + kongPort: ports.kongPort, + studioPort: ports.studioPort, + }) + } + + // Build response message + const messages: string[] = [] + if (domain) { + messages.push(apiDnsValid + ? 'API domain verified' + : 'API domain configured (DNS pending)') + } + if (studioDomain) { + messages.push(studioDnsValid + ? 'Studio domain verified' + : 'Studio domain configured (DNS pending)') + } return NextResponse.json({ success: true, domain: updatedProject.domain, verified: updatedProject.domainVerified, - message: dnsValid - ? 'Domain configured and verified successfully' - : 'Domain configured. DNS verification pending - make sure your domain points to this server.', + studioDomain: updatedProject.studioDomain, + studioVerified: updatedProject.studioDomainVerified, + message: messages.join('. ') || 'Domains updated successfully', }) } catch (error) { console.error('Set domain error:', error) @@ -148,7 +200,7 @@ export async function PUT( /** * DELETE /api/projects/[id]/domain - * Remove the custom domain from a project + * Remove the custom domains from a project */ export async function DELETE( request: NextRequest, @@ -176,12 +228,14 @@ export async function DELETE( return NextResponse.json({ error: 'Project not found' }, { status: 404 }) } - // Update the project (remove domain) + // Update the project (remove domains) await prisma.project.update({ where: { id }, data: { domain: null, domainVerified: false, + studioDomain: null, + studioDomainVerified: false, }, }) @@ -191,7 +245,7 @@ export async function DELETE( return NextResponse.json({ success: true, - message: 'Domain removed successfully', + message: 'Domains removed successfully', }) } catch (error) { console.error('Delete domain error:', error) diff --git a/src/app/api/settings/panel-domain/route.ts b/src/app/api/settings/panel-domain/route.ts new file mode 100644 index 0000000..212bd8d --- /dev/null +++ b/src/app/api/settings/panel-domain/route.ts @@ -0,0 +1,143 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { validateSession } from '@/lib/auth' +import { cookies } from 'next/headers' +import { generatePanelTraefikConfig, verifyDomainDNS } from '@/lib/traefik' + +/** + * GET /api/settings/panel-domain + * Get the panel domain configuration + */ +export async function GET() { + try { + // Validate session + const cookieStore = await cookies() + const sessionToken = cookieStore.get('session')?.value + if (!sessionToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const session = await validateSession(sessionToken) + if (!session) { + return NextResponse.json({ error: 'Invalid session' }, { status: 401 }) + } + + const domainSetting = await prisma.panelSettings.findUnique({ + where: { key: 'panel_domain' }, + }) + + const verifiedSetting = await prisma.panelSettings.findUnique({ + where: { key: 'panel_domain_verified' }, + }) + + return NextResponse.json({ + domain: domainSetting?.value || null, + verified: verifiedSetting?.value === 'true', + }) + } catch (error) { + console.error('Get panel domain error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +/** + * PUT /api/settings/panel-domain + * Set or update the panel domain + */ +export async function PUT(request: NextRequest) { + try { + // Validate session + const cookieStore = await cookies() + const sessionToken = cookieStore.get('session')?.value + if (!sessionToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const session = await validateSession(sessionToken) + if (!session) { + return NextResponse.json({ error: 'Invalid session' }, { status: 401 }) + } + + const { domain } = await request.json() + + if (!domain) { + return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) + } + + // Validate domain format + const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-_.]*\.[a-zA-Z]{2,}$/ + if (!domainRegex.test(domain)) { + return NextResponse.json({ error: 'Invalid domain format' }, { status: 400 }) + } + + // Verify domain DNS points to this server + const dnsValid = await verifyDomainDNS(domain) + + // Save the domain setting + await prisma.panelSettings.upsert({ + where: { key: 'panel_domain' }, + update: { value: domain }, + create: { key: 'panel_domain', value: domain }, + }) + + // Save the verification status + await prisma.panelSettings.upsert({ + where: { key: 'panel_domain_verified' }, + update: { value: dnsValid.toString() }, + create: { key: 'panel_domain_verified', value: dnsValid.toString() }, + }) + + // Generate Traefik configuration for the panel + await generatePanelTraefikConfig(domain) + + return NextResponse.json({ + success: true, + domain, + verified: dnsValid, + message: dnsValid + ? 'Panel domain configured and verified successfully. The panel will be accessible at https://' + domain + : 'Panel domain configured. DNS verification pending - make sure your domain points to this server.', + }) + } catch (error) { + console.error('Set panel domain error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +/** + * DELETE /api/settings/panel-domain + * Remove the panel domain + */ +export async function DELETE() { + try { + // Validate session + const cookieStore = await cookies() + const sessionToken = cookieStore.get('session')?.value + if (!sessionToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const session = await validateSession(sessionToken) + if (!session) { + return NextResponse.json({ error: 'Invalid session' }, { status: 401 }) + } + + // Delete the settings + await prisma.panelSettings.deleteMany({ + where: { + key: { + in: ['panel_domain', 'panel_domain_verified'], + }, + }, + }) + + // Remove Traefik configuration + const { removePanelTraefikConfig } = await import('@/lib/traefik') + await removePanelTraefikConfig() + + return NextResponse.json({ + success: true, + message: 'Panel domain removed successfully', + }) + } catch (error) { + console.error('Delete panel domain error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 49c0818..1f6f739 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -56,7 +56,7 @@ export default function DashboardPage() { try { setInitProgress('Creating directories...') await new Promise(resolve => setTimeout(resolve, 500)) // Small delay for UX - + setInitProgress('Cloning Supabase repository (this may take a few minutes)...') const response = await fetch('/api/projects/initialize', { method: 'POST', @@ -95,14 +95,14 @@ export default function DashboardPage() { const handleManageProject = async (project: Project) => { setSelectedProject(project) - + // Fetch project URLs from environment variables try { const response = await fetch(`/api/projects/${project.id}/env`) if (response.ok) { const data = await response.json() const envVars = data.envVars || {} - + // Extract URLs from environment variables const urls: Record = {} if (envVars.KONG_HTTP_PORT) { @@ -117,7 +117,7 @@ export default function DashboardPage() { if (envVars.POSTGRES_PORT) { urls['Database'] = `postgresql://postgres:${envVars.POSTGRES_PASSWORD || 'password'}@localhost:${envVars.POSTGRES_PORT}/postgres` } - + setProjectUrls(urls) } } catch (error) { @@ -127,7 +127,7 @@ export default function DashboardPage() { const handleDeleteProject = async () => { if (!selectedProject) return - + setDeleting(true) try { const response = await fetch(`/api/projects/${selectedProject.id}`, { @@ -159,7 +159,7 @@ export default function DashboardPage() { useEffect(() => { fetchProjects() - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) if (loading) { @@ -175,20 +175,29 @@ export default function DashboardPage() {
- SupaPanel
- +
+ + +
@@ -208,7 +217,7 @@ export default function DashboardPage() { {error} )} - + {initProgress && (
@@ -216,19 +225,19 @@ export default function DashboardPage() { {initProgress}
-
+
)} - - - +

This will clone the Supabase repository (~500MB) and set up the workspace

@@ -272,11 +281,10 @@ export default function DashboardPage() {
{project.name} -
+
{project.status}
@@ -322,11 +330,11 @@ export default function DashboardPage() {
- +

{selectedProject.name}

{selectedProject.description}

- + {/* Project URLs */}
Service URLs:
@@ -352,7 +360,7 @@ export default function DashboardPage() { )}
- +
- +

Are you sure you want to delete {selectedProject.name}? @@ -411,7 +419,7 @@ export default function DashboardPage() {

  • Delete the project from the database
  • - +
    + {domain && ( + + )} +
    + + {!domainVerified && domain && ( +
    + DNS Not Verified: Make sure your domain's A record points to this server's IP address. + DNS propagation can take up to 48 hours. +
    + )} + + {domain && ( +
    + Tip: When you save the domain, the URL fields below (Site URL, API External URL, Supabase Public URL) are automatically updated to use your custom domain. +
    + )} + + + + {/* Secrets Section */} @@ -320,7 +591,7 @@ export default function ConfigureProjectPage({ params }: ConfigureProjectPagePro These are the most critical security settings. Generate new secure values for production use. - - - - - + + + + +
    +
    +
    +

    Panel Settings

    +

    + Configure your SupaPanel dashboard settings +

    +
    + + {success && ( +
    + {success} +
    + )} + + {error && ( +
    + {error} +
    + )} + +
    + {/* Panel Domain Configuration */} + + + 🌐 Panel Domain + + Configure a custom domain for accessing this SupaPanel dashboard. + Traefik will automatically provision SSL certificates via Let's Encrypt. + + + +
    +
    +

    How Panel Domain Works

    +
      +
    1. Point your domain's DNS A record to this server's IP address
    2. +
    3. Enter your domain below (e.g., panel.example.com)
    4. +
    5. Traefik will automatically provision an SSL certificate
    6. +
    7. Access your panel at https://panel.example.com
    8. +
    +
    + +
    + +
    + setDomain(e.target.value)} + /> + {domain && ( +
    + {domainVerified ? '✓ Verified' : '⏳ Pending'} +
    + )} +
    +

    + Enter the domain you want to use for this SupaPanel dashboard (without https://) +

    +
    + + {domain && ( +
    + +
    + https://{domain} +
    +
    + )} + +
    + + {domain && ( + + )} +
    + + {!domainVerified && domain && ( +
    + DNS Not Verified: Make sure your domain's A record points to this server's IP address. + DNS propagation can take up to 48 hours. You can still access the panel via IP:3000 in the meantime. +
    + )} +
    +
    +
    + + {/* Current Access Info */} + + + 📌 Access Information + + Current ways to access your SupaPanel dashboard + + + +
    +
    + Direct IP Access: + http://YOUR_SERVER_IP:3000 +
    + {domain && domainVerified && ( +
    + Custom Domain: + + https://{domain} + +
    + )} +
    +
    +
    +
    +
    +
    + + ) +} diff --git a/src/lib/traefik.ts b/src/lib/traefik.ts index 05d159b..62a1b93 100644 --- a/src/lib/traefik.ts +++ b/src/lib/traefik.ts @@ -8,32 +8,35 @@ import * as path from 'path' // Get the base path for Traefik dynamic configs function getTraefikDynamicPath(): string { - const mode = process.env.SUPAPANEL_MODE || 'development' - if (mode === 'production') { - return '/etc/supapanel/traefik/dynamic' - } - return path.join(process.cwd(), 'traefik', 'dynamic') + const mode = process.env.SUPAPANEL_MODE || 'development' + if (mode === 'production') { + return '/etc/supapanel/traefik/dynamic' + } + return path.join(process.cwd(), 'traefik', 'dynamic') } interface TraefikConfig { - projectSlug: string - domain: string - kongPort: number - studioPort: number + projectSlug: string + domain: string // API domain (Kong) + studioDomain?: string // Studio domain (optional, separate from API) + kongPort: number + studioPort: number } /** * Generate Traefik routing configuration for a Supabase project + * Supports separate domains for API (Kong) and Studio */ export async function generateProjectTraefikConfig(config: TraefikConfig): Promise { - const { projectSlug, domain, kongPort, studioPort } = config + const { projectSlug, domain, studioDomain, kongPort, studioPort } = config - const traefikConfig = `# Auto-generated Traefik routing for project: ${projectSlug} -# Domain: ${domain} -# Generated at: ${new Date().toISOString()} + // Build routers section based on which domains are configured + let routersConfig = '' + let servicesConfig = '' -http: - routers: + // API domain configuration + if (domain) { + routersConfig += ` # Main API router (Kong gateway) ${projectSlug}-api: rule: "Host(\`${domain}\`)" @@ -44,17 +47,7 @@ http: certResolver: letsencrypt priority: 10 - # Studio router (subdomain) - ${projectSlug}-studio: - rule: "Host(\`studio.${domain}\`)" - entryPoints: - - websecure - service: ${projectSlug}-studio - tls: - certResolver: letsencrypt - priority: 10 - - # HTTP redirect routers + # HTTP redirect for API ${projectSlug}-api-http: rule: "Host(\`${domain}\`)" entryPoints: @@ -62,16 +55,58 @@ http: middlewares: - https-redirect service: ${projectSlug}-api - priority: 5 + priority: 5` + + servicesConfig += ` + ${projectSlug}-api: + loadBalancer: + servers: + - url: "http://${projectSlug}-kong:${kongPort}" + healthCheck: + path: /health + interval: 30s + timeout: 5s` + } + + // Studio domain configuration + if (studioDomain) { + routersConfig += ` + + # Studio router + ${projectSlug}-studio: + rule: "Host(\`${studioDomain}\`)" + entryPoints: + - websecure + service: ${projectSlug}-studio + tls: + certResolver: letsencrypt + priority: 10 + # HTTP redirect for Studio ${projectSlug}-studio-http: - rule: "Host(\`studio.${domain}\`)" + rule: "Host(\`${studioDomain}\`)" entryPoints: - web middlewares: - https-redirect service: ${projectSlug}-studio - priority: 5 + priority: 5` + + servicesConfig += ` + + ${projectSlug}-studio: + loadBalancer: + servers: + - url: "http://${projectSlug}-studio:${studioPort}"` + } + + const traefikConfig = `# Auto-generated Traefik routing for project: ${projectSlug} +# API Domain: ${domain || 'not configured'} +# Studio Domain: ${studioDomain || 'not configured'} +# Generated at: ${new Date().toISOString()} + +http: + routers:${routersConfig} middlewares: https-redirect: @@ -79,75 +114,62 @@ http: scheme: https permanent: true - services: - ${projectSlug}-api: - loadBalancer: - servers: - - url: "http://${projectSlug}-kong:${kongPort}" - healthCheck: - path: /health - interval: 30s - timeout: 5s - - ${projectSlug}-studio: - loadBalancer: - servers: - - url: "http://${projectSlug}-studio:${studioPort}" + services:${servicesConfig} ` - const configPath = path.join(getTraefikDynamicPath(), `${projectSlug}.yml`) + const configPath = path.join(getTraefikDynamicPath(), `${projectSlug}.yml`) - // Ensure directory exists - await fs.mkdir(getTraefikDynamicPath(), { recursive: true }) + // Ensure directory exists + await fs.mkdir(getTraefikDynamicPath(), { recursive: true }) - // Write the config file - await fs.writeFile(configPath, traefikConfig) + // Write the config file + await fs.writeFile(configPath, traefikConfig) - console.log(`Traefik config generated for project ${projectSlug} at ${configPath}`) + console.log(`Traefik config generated for project ${projectSlug} at ${configPath}`) } /** * Update an existing project's Traefik config with a new domain */ export async function updateProjectDomain( - projectSlug: string, - newDomain: string, - kongPort: number, - studioPort: number + projectSlug: string, + newDomain: string, + kongPort: number, + studioPort: number ): Promise { - await generateProjectTraefikConfig({ - projectSlug, - domain: newDomain, - kongPort, - studioPort, - }) + await generateProjectTraefikConfig({ + projectSlug, + domain: newDomain, + kongPort, + studioPort, + }) } /** * Remove Traefik configuration for a deleted project */ export async function removeProjectTraefikConfig(projectSlug: string): Promise { - const configPath = path.join(getTraefikDynamicPath(), `${projectSlug}.yml`) - - try { - await fs.unlink(configPath) - console.log(`Traefik config removed for project ${projectSlug}`) - } catch (error) { - // File may not exist if project never had a custom domain - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error(`Failed to remove Traefik config for ${projectSlug}:`, error) - } + const configPath = path.join(getTraefikDynamicPath(), `${projectSlug}.yml`) + + try { + await fs.unlink(configPath) + console.log(`Traefik config removed for project ${projectSlug}`) + } catch (error) { + // File may not exist if project never had a custom domain + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error(`Failed to remove Traefik config for ${projectSlug}:`, error) } + } } /** * Get the ports used by a project's services from environment variables */ export function getProjectPorts(envVars: Record): { kongPort: number; studioPort: number } { - return { - kongPort: parseInt(envVars.KONG_HTTP_PORT || '8000', 10), - studioPort: parseInt(envVars.STUDIO_PORT || '3000', 10), - } + return { + kongPort: parseInt(envVars.KONG_HTTP_PORT || '8000', 10), + studioPort: parseInt(envVars.STUDIO_PORT || '3000', 10), + } } /** @@ -155,15 +177,89 @@ export function getProjectPorts(envVars: Record): { kongPort: nu * Returns true if the domain resolves to an IP that could be this server */ export async function verifyDomainDNS(domain: string): Promise { - try { - // Use Node's DNS module for resolution - const dns = await import('dns').then(m => m.promises) - const addresses = await dns.resolve4(domain) - - // Domain resolves to at least one IP - return addresses.length > 0 - } catch { - // DNS resolution failed - return false + try { + // Use Node's DNS module for resolution + const dns = await import('dns').then(m => m.promises) + const addresses = await dns.resolve4(domain) + + // Domain resolves to at least one IP + return addresses.length > 0 + } catch { + // DNS resolution failed + return false + } +} + +/** + * Generate Traefik routing configuration for the SupaPanel itself + */ +export async function generatePanelTraefikConfig(domain: string): Promise { + const traefikConfig = `# Auto-generated Traefik routing for SupaPanel +# Domain: ${domain} +# Generated at: ${new Date().toISOString()} + +http: + routers: + # Panel HTTPS router + supapanel: + rule: "Host(\`${domain}\`)" + entryPoints: + - websecure + service: supapanel + tls: + certResolver: letsencrypt + priority: 100 + + # Panel HTTP redirect router + supapanel-http: + rule: "Host(\`${domain}\`)" + entryPoints: + - web + middlewares: + - https-redirect + service: supapanel + priority: 50 + + middlewares: + https-redirect: + redirectScheme: + scheme: https + permanent: true + + services: + supapanel: + loadBalancer: + servers: + - url: "http://supapanel-panel:3000" +` + + const configPath = path.join(getTraefikDynamicPath(), 'panel.yml') + + // Ensure directory exists + await fs.mkdir(getTraefikDynamicPath(), { recursive: true }) + + // Write the config file + await fs.writeFile(configPath, traefikConfig) + + console.log(`Panel Traefik config generated at ${configPath}`) +} + +/** + * Remove Traefik configuration for the panel domain + */ +export async function removePanelTraefikConfig(): Promise { + const configPath = path.join(getTraefikDynamicPath(), 'panel.yml') + + try { + // Write empty config (just a comment) + const emptyConfig = `# SupaPanel Panel Routing +# No custom domain configured - access via IP:3000 +` + await fs.writeFile(configPath, emptyConfig) + console.log(`Panel Traefik config cleared at ${configPath}`) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('Failed to remove panel Traefik config:', error) } + } } diff --git a/traefik/dynamic/panel.yml b/traefik/dynamic/panel.yml deleted file mode 100644 index 1d383c6..0000000 --- a/traefik/dynamic/panel.yml +++ /dev/null @@ -1,17 +0,0 @@ -# Panel Routing Configuration -# This file is created by the install script and can be customized for panel domain - -http: - routers: - panel-http: - rule: "PathPrefix(`/`)" - entryPoints: - - web - service: panel - priority: 1 - - services: - panel: - loadBalancer: - servers: - - url: "http://supapanel-panel:3000"