diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index c211e91b..65b01b26 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -35,13 +35,8 @@ jobs: - name: Update version in package.json run: | TAG=${{ env.TAG }} - if [ -f package.json ]; then - jq --arg version "$TAG" '.version = $version' package.json > package.tmp.json && mv package.tmp.json package.json - echo "Updated package.json with version $TAG" - else - echo "package.json not found" - fi - cat package.json + sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts + cat server/lib/ - name: Pull latest Gerbil version id: get-gerbil-tag diff --git a/docker-compose.example.yml b/docker-compose.example.yml index b6184c67..bc5ad10c 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -37,7 +37,7 @@ services: - 80:80 # Port for traefik because of the network_mode traefik: - image: traefik:v3.1 + image: traefik:v3.3.3 container_name: traefik restart: unless-stopped network_mode: service:gerbil # Ports appear on the gerbil service @@ -49,3 +49,8 @@ services: volumes: - ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration - ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates + +networks: + default: + driver: bridge + name: pangolin \ No newline at end of file diff --git a/install/fs/docker-compose.yml b/install/fs/docker-compose.yml index ab6528d0..ea673eb0 100644 --- a/install/fs/docker-compose.yml +++ b/install/fs/docker-compose.yml @@ -36,7 +36,7 @@ services: {{end}} traefik: - image: traefik:v3.1 + image: traefik:v3.3.3 container_name: traefik restart: unless-stopped {{if .InstallGerbil}} @@ -55,3 +55,8 @@ services: volumes: - ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates + +networks: + default: + driver: bridge + name: pangolin \ No newline at end of file diff --git a/server/db/schema.ts b/server/db/schema.ts index b87acd91..f44873d1 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -53,7 +53,8 @@ export const resources = sqliteTable("resources", { proxyPort: integer("proxyPort"), emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) .notNull() - .default(false) + .default(false), + isBaseDomain: integer("isBaseDomain", { mode: "boolean" }) }); export const targets = sqliteTable("targets", { diff --git a/server/lib/config.ts b/server/lib/config.ts index 1e6821ae..7c5ad227 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -151,7 +151,8 @@ const configSchema = z.object({ require_email_verification: z.boolean().optional(), disable_signup_without_invite: z.boolean().optional(), disable_user_create_org: z.boolean().optional(), - allow_raw_resources: z.boolean().optional() + allow_raw_resources: z.boolean().optional(), + allow_base_domain_resources: z.boolean().optional() }) .optional() }); @@ -251,9 +252,9 @@ export class Config { ? "true" : "false"; process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.data.flags - ?.allow_raw_resources - ? "true" - : "false"; + ?.allow_raw_resources + ? "true" + : "false"; process.env.SESSION_COOKIE_NAME = parsedConfig.data.server.session_cookie_name; process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false"; @@ -269,6 +270,11 @@ export class Config { parsedConfig.data.server.resource_access_token_param; process.env.RESOURCE_SESSION_REQUEST_PARAM = parsedConfig.data.server.resource_session_request_param; + process.env.FLAGS_ALLOW_BASE_DOMAIN_RESOURCES = parsedConfig.data.flags + ?.allow_base_domain_resources + ? "true" + : "false"; + process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url; this.rawConfig = parsedConfig.data; } diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index a5669a1d..9f7fa1fb 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -34,7 +34,8 @@ const createResourceSchema = z siteId: z.number(), http: z.boolean(), protocol: z.string(), - proxyPort: z.number().optional() + proxyPort: z.number().optional(), + isBaseDomain: z.boolean().optional() }) .refine( (data) => { @@ -55,7 +56,7 @@ const createResourceSchema = z ) .refine( (data) => { - if (data.http) { + if (data.http && !data.isBaseDomain) { return subdomainSchema.safeParse(data.subdomain).success; } return true; @@ -75,18 +76,31 @@ const createResourceSchema = z return true; }, { - message: "Cannot update proxyPort" + message: "Proxy port cannot be set" } ) + // .refine( + // (data) => { + // if (data.proxyPort === 443 || data.proxyPort === 80) { + // return false; + // } + // return true; + // }, + // { + // message: "Port 80 and 443 are reserved for http and https resources" + // } + // ) .refine( (data) => { - if (data.proxyPort === 443 || data.proxyPort === 80) { - return false; + if (!config.getRawConfig().flags?.allow_base_domain_resources) { + if (data.isBaseDomain) { + return false; + } } return true; }, { - message: "Port 80 and 443 are reserved for http and https resources" + message: "Base domain resources are not allowed" } ); @@ -108,7 +122,7 @@ export async function createResource( ); } - let { name, subdomain, protocol, proxyPort, http } = parsedBody.data; + let { name, subdomain, protocol, proxyPort, http, isBaseDomain } = parsedBody.data; // Validate request params const parsedParams = createResourceParamsSchema.safeParse(req.params); @@ -145,7 +159,13 @@ export async function createResource( ); } - const fullDomain = `${subdomain}.${org[0].domain}`; + let fullDomain = ""; + if (isBaseDomain) { + fullDomain = org[0].domain; + } else { + fullDomain = `${subdomain}.${org[0].domain}`; + } + // if http is false check to see if there is already a resource with the same port and protocol if (!http) { const existingResource = await db @@ -195,7 +215,8 @@ export async function createResource( http, protocol, proxyPort, - ssl: true + ssl: true, + isBaseDomain }) .returning(); diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 6910bd76..94958978 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -28,7 +28,8 @@ const updateResourceBodySchema = z sso: z.boolean().optional(), blockAccess: z.boolean().optional(), proxyPort: z.number().int().min(1).max(65535).optional(), - emailWhitelistEnabled: z.boolean().optional() + emailWhitelistEnabled: z.boolean().optional(), + isBaseDomain: z.boolean().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -45,15 +46,28 @@ const updateResourceBodySchema = z }, { message: "Cannot update proxyPort" } ) + // .refine( + // (data) => { + // if (data.proxyPort === 443 || data.proxyPort === 80) { + // return false; + // } + // return true; + // }, + // { + // message: "Port 80 and 443 are reserved for http and https resources" + // } + // ) .refine( (data) => { - if (data.proxyPort === 443 || data.proxyPort === 80) { - return false; + if (!config.getRawConfig().flags?.allow_base_domain_resources) { + if (data.isBaseDomain) { + return false; + } } return true; }, { - message: "Port 80 and 443 are reserved for http and https resources" + message: "Base domain resources are not allowed" } ); @@ -104,6 +118,29 @@ export async function updateResource( ); } + if (updateData.subdomain) { + if (!resource.http) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot update subdomain for non-http resource" + ) + ); + } + + const valid = subdomainSchema.safeParse( + updateData.subdomain + ).success; + if (!valid) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid subdomain provided" + ) + ); + } + } + if (updateData.proxyPort) { const proxyPort = updateData.proxyPort; const existingResource = await db @@ -138,15 +175,32 @@ export async function updateResource( ); } - const fullDomain = updateData.subdomain - ? `${updateData.subdomain}.${org.domain}` - : undefined; + let fullDomain = ""; + if (updateData.isBaseDomain) { + fullDomain = org.domain; + } else { + fullDomain = `${updateData.subdomain}.${org.domain}`; + } const updatePayload = { ...updateData, ...(fullDomain && { fullDomain }) }; + const [existingDomain] = await db + .select() + .from(resources) + .where(eq(resources.fullDomain, fullDomain)); + + if (existingDomain && existingDomain.resourceId !== resourceId) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that domain already exists" + ) + ); + } + const updatedResource = await db .update(resources) .set(updatePayload) diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index ea00a33d..14ee8076 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -25,6 +25,7 @@ export async function traefikConfigProvider( http: resources.http, proxyPort: resources.proxyPort, protocol: resources.protocol, + isBaseDomain: resources.isBaseDomain, // Site fields site: { siteId: sites.siteId, @@ -110,11 +111,11 @@ export async function traefikConfigProvider( const routerName = `${resource.resourceId}-router`; const serviceName = `${resource.resourceId}-service`; - const fullDomain = `${resource.subdomain}.${org.domain}`; + const fullDomain = `${resource.fullDomain}`; if (resource.http) { // HTTP configuration remains the same - if (!resource.subdomain) { + if (!resource.subdomain && !resource.isBaseDomain) { continue; } @@ -148,6 +149,8 @@ export async function traefikConfigProvider( : {}) }; + logger.debug(config.getRawConfig().traefik.prefer_wildcard_cert) + const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index 5a5e6711..8f3af8d6 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -23,7 +23,12 @@ export async function copyInConfig() { const allResources = await trx.select().from(resources); for (const resource of allResources) { - const fullDomain = `${resource.subdomain}.${domain}`; + let fullDomain = ""; + if (resource.isBaseDomain) { + fullDomain = domain; + } else { + fullDomain = `${resource.subdomain}.${domain}`; + } await trx .update(resources) .set({ fullDomain }) diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index e0e25f15..5581fc24 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -3,8 +3,9 @@ import db, { exists } from "@server/db"; import path from "path"; import semver from "semver"; import { versionMigrations } from "@server/db/schema"; -import { __DIRNAME, APP_VERSION } from "@server/lib/consts"; +import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts"; import { SqliteError } from "better-sqlite3"; +import fs from "fs"; import m1 from "./scripts/1.0.0-beta1"; import m2 from "./scripts/1.0.0-beta2"; import m3 from "./scripts/1.0.0-beta3"; @@ -12,6 +13,7 @@ import m4 from "./scripts/1.0.0-beta5"; import m5 from "./scripts/1.0.0-beta6"; import m6 from "./scripts/1.0.0-beta9"; import m7 from "./scripts/1.0.0-beta10"; +import m8 from "./scripts/1.0.0-beta12"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -24,12 +26,41 @@ const migrations = [ { version: "1.0.0-beta.5", run: m4 }, { version: "1.0.0-beta.6", run: m5 }, { version: "1.0.0-beta.9", run: m6 }, - { version: "1.0.0-beta.10", run: m7 } + { version: "1.0.0-beta.10", run: m7 }, + { version: "1.0.0-beta.12", run: m8 } // Add new migrations here as they are created ] as const; -// Run the migrations -await runMigrations(); +await run(); + +async function run() { + // backup the database + backupDb(); + + // run the migrations + await runMigrations(); +} + +function backupDb() { + // make dir config/db/backups + const appPath = APP_PATH; + const dbDir = path.join(appPath, "db"); + + const backupsDir = path.join(dbDir, "backups"); + + // check if the backups directory exists and create it if it doesn't + if (!fs.existsSync(backupsDir)) { + fs.mkdirSync(backupsDir, { recursive: true }); + } + + // copy the db.sqlite file to backups + // add the date to the filename + const date = new Date(); + const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`; + const dbPath = path.join(dbDir, "db.sqlite"); + const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`); + fs.copyFileSync(dbPath, backupPath); +} export async function runMigrations() { try { @@ -105,7 +136,10 @@ async function executeScripts() { `Successfully completed migration ${migration.version}` ); } catch (e) { - if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { + if ( + e instanceof SqliteError && + e.code === "SQLITE_CONSTRAINT_UNIQUE" + ) { console.error("Migration has already run! Skipping..."); continue; } diff --git a/server/setup/scripts/1.0.0-beta12.ts b/server/setup/scripts/1.0.0-beta12.ts new file mode 100644 index 00000000..0632b5e1 --- /dev/null +++ b/server/setup/scripts/1.0.0-beta12.ts @@ -0,0 +1,62 @@ +import db from "@server/db"; +import { configFilePath1, configFilePath2 } from "@server/lib/consts"; +import { sql } from "drizzle-orm"; +import fs from "fs"; +import yaml from "js-yaml"; + +export default async function migration() { + console.log("Running setup script 1.0.0-beta.12..."); + + try { + // Determine which config file exists + const filePaths = [configFilePath1, configFilePath2]; + let filePath = ""; + for (const path of filePaths) { + if (fs.existsSync(path)) { + filePath = path; + break; + } + } + + if (!filePath) { + throw new Error( + `No config file found (expected config.yml or config.yaml).` + ); + } + + // Read and parse the YAML file + let rawConfig: any; + const fileContents = fs.readFileSync(filePath, "utf8"); + rawConfig = yaml.load(fileContents); + + if (!rawConfig.flags) { + rawConfig.flags = {}; + } + + rawConfig.flags.allow_base_domain_resources = true; + + // Write the updated YAML back to the file + const updatedYaml = yaml.dump(rawConfig); + fs.writeFileSync(filePath, updatedYaml, "utf8"); + + console.log(`Added new config option: allow_base_domain_resources`); + } catch (e) { + console.log( + `Unable to add new config option: allow_base_domain_resources. This is not critical.` + ); + console.error(e); + } + + try { + db.transaction((trx) => { + trx.run(sql`ALTER TABLE 'resources' ADD 'isBaseDomain' integer;`); + }); + + console.log(`Added new column: isBaseDomain`); + } catch (e) { + console.log("Unable to add new column: isBaseDomain"); + throw e; + } + + console.log("Done."); +} diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index 4add3044..6e33ec79 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -63,6 +63,8 @@ import { subdomainSchema } from "@server/schemas/subdomainSchema"; import Link from "next/link"; import { SquareArrowOutUpRight } from "lucide-react"; import CopyTextBox from "@app/components/CopyTextBox"; +import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; +import { Label } from "@app/components/ui/label"; const createResourceFormSchema = z .object({ @@ -71,7 +73,8 @@ const createResourceFormSchema = z siteId: z.number(), http: z.boolean(), protocol: z.string(), - proxyPort: z.number().optional() + proxyPort: z.number().optional(), + isBaseDomain: z.boolean().optional() }) .refine( (data) => { @@ -92,7 +95,7 @@ const createResourceFormSchema = z ) .refine( (data) => { - if (data.http) { + if (data.http && !data.isBaseDomain) { return subdomainSchema.safeParse(data.subdomain).success; } return true; @@ -131,12 +134,15 @@ export default function CreateResourceForm({ const [domainSuffix, setDomainSuffix] = useState(org.org.domain); const [showSnippets, setShowSnippets] = useState(false); const [resourceId, setResourceId] = useState(null); + const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( + "subdomain" + ); const form = useForm({ resolver: zodResolver(createResourceFormSchema), defaultValues: { subdomain: "", - name: "My Resource", + name: "", http: true, protocol: "tcp" } @@ -180,7 +186,8 @@ export default function CreateResourceForm({ http: data.http, protocol: data.protocol, proxyPort: data.http ? undefined : data.proxyPort, - siteId: data.siteId + siteId: data.siteId, + isBaseDomain: data.isBaseDomain } ) .catch((e) => { @@ -246,7 +253,7 @@ export default function CreateResourceForm({ Name @@ -291,33 +298,89 @@ export default function CreateResourceForm({ /> )} + {form.watch("http") && + env.flags.allowBaseDomainResources && ( +
+ { + setDomainType( + val as any + ); + form.setValue( + "isBaseDomain", + val === "basedomain" + ); + }} + > +
+ + +
+
+ + +
+
+
+ )} + {form.watch("http") && ( ( - - Subdomain - - - - form.setValue( - "subdomain", + {!env.flags + .allowBaseDomainResources && ( + + Subdomain + + )} + {domainType === + "subdomain" ? ( + + - + ) => + form.setValue( + "subdomain", + value + ) + } + /> + + ) : ( + + + + )} This is the fully qualified domain name @@ -471,9 +534,7 @@ export default function CreateResourceForm({ site ) => ( - {!showSnippets && } + {!showSnippets && ( + + )} - {showSnippets && } + {showSnippets && ( + + )} diff --git a/src/app/[orgId]/settings/resources/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/ResourcesTable.tsx index 463c8461..fee92999 100644 --- a/src/app/[orgId]/settings/resources/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/ResourcesTable.tsx @@ -38,7 +38,7 @@ export type ResourceRow = { domain: string; site: string; siteId: string; - hasAuth: boolean; + authState: string; http: boolean; protocol: string; proxyPort: number | null; @@ -165,9 +165,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { header: "Protocol", cell: ({ row }) => { const resourceRow = row.original; - return ( - {resourceRow.protocol.toUpperCase()} - ); + return {resourceRow.protocol.toUpperCase()}; } }, { @@ -177,17 +175,23 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const resourceRow = row.original; return (
- {!resourceRow.http ? ( - - ) : ( - - )} + {!resourceRow.http ? ( + + ) : ( + + )}
); } }, { - accessorKey: "hasAuth", + accessorKey: "authState", header: ({ column }) => { return (