diff --git a/messages/en-US.json b/messages/en-US.json index a9051087..8e78c3d2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1312,5 +1312,8 @@ "sitesFetchFailed": "Failed to fetch sites", "sitesFetchError": "An error occurred while fetching sites.", "olmErrorFetchReleases": "An error occurred while fetching Olm releases.", - "olmErrorFetchLatest": "An error occurred while fetching the latest Olm release." + "olmErrorFetchLatest": "An error occurred while fetching the latest Olm release.", + "remoteSubnets": "Remote Subnets", + "enterCidrRange": "Enter CIDR range", + "remoteSubnetsDescription": "Add CIDR ranges that can access this site remotely. Use format like 10.0.0.0/24 or 192.168.1.0/24." } \ No newline at end of file diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 77be5f1b..d774a985 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -59,7 +59,8 @@ export const sites = pgTable("sites", { publicKey: varchar("publicKey"), lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), - dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true) + dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), + remoteSubnets: text("remoteSubnets") // comma-separated list of subnets that this site can access }); export const resources = pgTable("resources", { @@ -542,7 +543,7 @@ export const olmSessions = pgTable("clientSession", { olmId: varchar("olmId") .notNull() .references(() => olms.olmId, { onDelete: "cascade" }), - expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), + expiresAt: bigint("expiresAt", { mode: "number" }).notNull() }); export const userClients = pgTable("userClients", { @@ -565,9 +566,11 @@ export const roleClients = pgTable("roleClients", { export const securityKeys = pgTable("webauthnCredentials", { credentialId: varchar("credentialId").primaryKey(), - userId: varchar("userId").notNull().references(() => users.userId, { - onDelete: "cascade" - }), + userId: varchar("userId") + .notNull() + .references(() => users.userId, { + onDelete: "cascade" + }), publicKey: varchar("publicKey").notNull(), signCount: integer("signCount").notNull(), transports: varchar("transports"), diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 2c44b593..d372856d 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -65,7 +65,8 @@ export const sites = sqliteTable("sites", { listenPort: integer("listenPort"), dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) .notNull() - .default(true) + .default(true), + remoteSubnets: text("remoteSubnets"), // comma-separated list of subnets that this site can access }); export const resources = sqliteTable("resources", { diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index a5a5f7c0..e3724f36 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -9,6 +9,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { isValidCIDR } from "@server/lib/validators"; const updateSiteParamsSchema = z .object({ @@ -20,6 +21,9 @@ const updateSiteBodySchema = z .object({ name: z.string().min(1).max(255).optional(), dockerSocketEnabled: z.boolean().optional(), + remoteSubnets: z + .string() + .optional() // subdomain: z // .string() // .min(1) @@ -85,6 +89,21 @@ export async function updateSite( const { siteId } = parsedParams.data; const updateData = parsedBody.data; + // if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs + if (updateData.remoteSubnets) { + const subnets = updateData.remoteSubnets.split(",").map((s) => s.trim()); + for (const subnet of subnets) { + if (!isValidCIDR(subnet)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Invalid CIDR format: ${subnet}` + ) + ); + } + } + } + const updatedSite = await db .update(sites) .set(updateData) diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index ba1f877c..1581d961 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -33,10 +33,17 @@ import { useState } from "react"; import { SwitchInput } from "@app/components/SwitchInput"; import { useTranslations } from "next-intl"; import Link from "next/link"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), - dockerSocketEnabled: z.boolean().optional() + dockerSocketEnabled: z.boolean().optional(), + remoteSubnets: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ).optional() }); type GeneralFormValues = z.infer; @@ -44,9 +51,11 @@ type GeneralFormValues = z.infer; export default function GeneralPage() { const { site, updateSite } = useSiteContext(); + const { env } = useEnvContext(); const api = createApiClient(useEnvContext()); const [loading, setLoading] = useState(false); + const [activeCidrTagIndex, setActiveCidrTagIndex] = useState(null); const router = useRouter(); const t = useTranslations(); @@ -55,7 +64,13 @@ export default function GeneralPage() { resolver: zodResolver(GeneralFormSchema), defaultValues: { name: site?.name, - dockerSocketEnabled: site?.dockerSocketEnabled ?? false + dockerSocketEnabled: site?.dockerSocketEnabled ?? false, + remoteSubnets: site?.remoteSubnets + ? site.remoteSubnets.split(',').map((subnet, index) => ({ + id: subnet.trim(), + text: subnet.trim() + })) + : [] }, mode: "onChange" }); @@ -66,7 +81,8 @@ export default function GeneralPage() { await api .post(`/site/${site?.siteId}`, { name: data.name, - dockerSocketEnabled: data.dockerSocketEnabled + dockerSocketEnabled: data.dockerSocketEnabled, + remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || '' }) .catch((e) => { toast({ @@ -81,7 +97,8 @@ export default function GeneralPage() { updateSite({ name: data.name, - dockerSocketEnabled: data.dockerSocketEnabled + dockerSocketEnabled: data.dockerSocketEnabled, + remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || '' }); toast({ @@ -124,12 +141,47 @@ export default function GeneralPage() { - - {t("siteNameDescription")} - )} /> + + ( + + {t("remoteSubnets")} + + { + form.setValue( + "remoteSubnets", + newSubnets as Tag[] + ); + }} + validateTag={(tag) => { + // Basic CIDR validation regex + const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/; + return cidrRegex.test(tag); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("remoteSubnetsDescription")} + + + + )} + /> + {site && site.type === "newt" && (