Compare commits

...

14 Commits
1.16.1 ... main

Author SHA1 Message Date
Owen Schwartz
44e3eedffa Merge pull request #2567 from marcschaeferger/fix-kubernetes-install
feat(kubernetes): enable newtInstances by default and update installation instructions
2026-03-01 10:56:18 -08:00
Marc Schäfer
bb189874cb fix(newt-install): conditionally display Kubernetes installation info
Signed-off-by: Marc Schäfer <git@marcschaeferger.de>
2026-03-01 10:55:58 -08:00
Marc Schäfer
34dadd0e16 feat(kubernetes): enable newtInstances by default and update installation instructions
Signed-off-by: Marc Schäfer <git@marcschaeferger.de>
2026-03-01 10:55:58 -08:00
Owen Schwartz
87b5cd9988 Merge pull request #2573 from Fizza-Mukhtar/fix/container-search-excludes-labels-2228
fix: exclude labels from container search to prevent false positives
2026-03-01 10:52:50 -08:00
Marc Schäfer
6a537a23e8 fix(newt-install): conditionally display Kubernetes installation info
Signed-off-by: Marc Schäfer <git@marcschaeferger.de>
2026-03-01 18:17:45 +01:00
Fizza-Mukhtar
7ce589c4f2 fix: exclude labels from container search to prevent false positives 2026-03-01 06:50:03 -08:00
Marc Schäfer
375211f184 feat(kubernetes): enable newtInstances by default and update installation instructions
Signed-off-by: Marc Schäfer <git@marcschaeferger.de>
2026-02-28 23:56:28 +01:00
Owen
66c377a5c9 Merge branch 'main' into dev 2026-02-28 12:14:41 -08:00
Owen
50c2aa0111 Add default memory limits 2026-02-28 12:14:27 -08:00
Owen
fdeb891137 Fix pagination effecting drop downs 2026-02-28 12:07:42 -08:00
Owen Schwartz
6a6e3a43b1 Merge pull request #2562 from LaurenceJJones/fix/zod-openapi-catch-error
fix(zod): Add openapi call after catch
2026-02-28 11:04:10 -08:00
Laurence
b0a34fa21b fix(openapi): Add openapi call after catch
fix: #2561
without making an explicit call to openapi a runtime error happens because it cannot infer the type, the call to openapi is the same across the codebase
2026-02-28 11:27:19 +00:00
Owen
72bf6f3c41 Comma seperated 2026-02-27 17:53:44 -08:00
miloschwartz
ad9289e0c1 sort by name by default 2026-02-27 15:53:27 -08:00
14 changed files with 331 additions and 275 deletions

View File

@@ -4,6 +4,12 @@ services:
image: fosrl/pangolin:latest image: fosrl/pangolin:latest
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 1g
reservations:
memory: 256m
volumes: volumes:
- ./config:/app/config - ./config:/app/config
healthcheck: healthcheck:

View File

@@ -4,6 +4,12 @@ services:
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}} image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}}
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 1g
reservations:
memory: 256m
volumes: volumes:
- ./config:/app/config - ./config:/app/config
healthcheck: healthcheck:

View File

@@ -1670,10 +1670,10 @@
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.", "sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
"sshSudo": "Allow sudo", "sshSudo": "Allow sudo",
"sshSudoCommands": "Sudo Commands", "sshSudoCommands": "Sudo Commands",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo.", "sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo.",
"sshCreateHomeDir": "Create Home Directory", "sshCreateHomeDir": "Create Home Directory",
"sshUnixGroups": "Unix Groups", "sshUnixGroups": "Unix Groups",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host.", "sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.",
"retryAttempts": "Retry Attempts", "retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes", "expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",

View File

@@ -370,7 +370,7 @@ export async function listClients(
? order === "asc" ? order === "asc"
? asc(clients[sort_by]) ? asc(clients[sort_by])
: desc(clients[sort_by]) : desc(clients[sort_by])
: asc(clients.clientId) : asc(clients.name)
); );
const [clientsList, totalCount] = await Promise.all([ const [clientsList, totalCount] = await Promise.all([

View File

@@ -429,7 +429,7 @@ export async function listResources(
? order === "asc" ? order === "asc"
? asc(resources[sort_by]) ? asc(resources[sort_by])
: desc(resources[sort_by]) : desc(resources[sort_by])
: asc(resources.resourceId) : asc(resources.name)
), ),
countQuery countQuery
]); ]);

View File

@@ -289,7 +289,7 @@ export async function listSites(
? order === "asc" ? order === "asc"
? asc(sites[sort_by]) ? asc(sites[sort_by])
: desc(sites[sort_by]) : desc(sites[sort_by])
: asc(sites.siteId) : asc(sites.name)
); );
const [totalCount, rows] = await Promise.all([ const [totalCount, rows] = await Promise.all([

View File

@@ -205,7 +205,7 @@ export async function listAllSiteResourcesByOrg(
? order === "asc" ? order === "asc"
? asc(siteResources[sort_by]) ? asc(siteResources[sort_by])
: desc(siteResources[sort_by]) : desc(siteResources[sort_by])
: asc(siteResources.siteResourceId) : asc(siteResources.name)
), ),
countQuery countQuery
]); ]);

View File

@@ -31,12 +31,23 @@ const listSiteResourcesQuerySchema = z.object({
sort_by: z sort_by: z
.enum(["name"]) .enum(["name"])
.optional() .optional()
.catch(undefined), .catch(undefined)
.openapi({
type: "string",
enum: ["name"],
description: "Field to sort by"
}),
order: z order: z
.enum(["asc", "desc"]) .enum(["asc", "desc"])
.optional() .optional()
.default("asc") .default("asc")
.catch("asc") .catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
})
}); });
export type ListSiteResourcesResponse = { export type ListSiteResourcesResponse = {
@@ -112,7 +123,7 @@ export async function listSiteResources(
? order === "asc" ? order === "asc"
? asc(siteResources[sort_by]) ? asc(siteResources[sort_by])
: desc(siteResources[sort_by]) : desc(siteResources[sort_by])
: asc(siteResources.siteResourceId) : asc(siteResources.name)
) )
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);

View File

@@ -89,7 +89,14 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { use, useActionState, useCallback, useEffect, useMemo, useState } from "react"; import {
use,
useActionState,
useCallback,
useEffect,
useMemo,
useState
} from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -184,7 +191,8 @@ function ProxyResourceTargetsForm({
setDockerStates((prev) => new Map(prev.set(siteId, dockerState))); setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
}; };
const refreshContainersForSite = useCallback(async (siteId: number) => { const refreshContainersForSite = useCallback(
async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId); const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers(); const containers = await dockerManager.fetchContainers();
@@ -196,9 +204,12 @@ function ProxyResourceTargetsForm({
} }
return newMap; return newMap;
}); });
}, [api]); },
[api]
);
const getDockerStateForSite = useCallback((siteId: number): DockerState => { const getDockerStateForSite = useCallback(
(siteId: number): DockerState => {
return ( return (
dockerStates.get(siteId) || { dockerStates.get(siteId) || {
isEnabled: false, isEnabled: false,
@@ -206,7 +217,9 @@ function ProxyResourceTargetsForm({
containers: [] containers: []
} }
); );
}, [dockerStates]); },
[dockerStates]
);
const [isAdvancedMode, setIsAdvancedMode] = useState(() => { const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -220,7 +233,9 @@ function ProxyResourceTargetsForm({
const removeTarget = useCallback((targetId: number) => { const removeTarget = useCallback((targetId: number) => {
setTargets((prevTargets) => { setTargets((prevTargets) => {
const targetToRemove = prevTargets.find((target) => target.targetId === targetId); const targetToRemove = prevTargets.find(
(target) => target.targetId === targetId
);
if (targetToRemove && !targetToRemove.new) { if (targetToRemove && !targetToRemove.new) {
setTargetsToRemove((prev) => [...prev, targetId]); setTargetsToRemove((prev) => [...prev, targetId]);
} }
@@ -228,7 +243,8 @@ function ProxyResourceTargetsForm({
}); });
}, []); }, []);
const updateTarget = useCallback((targetId: number, data: Partial<LocalTarget>) => { const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => { setTargets((prevTargets) => {
const site = sites.find((site) => site.siteId === data.siteId); const site = sites.find((site) => site.siteId === data.siteId);
return prevTargets.map((target) => return prevTargets.map((target) =>
@@ -242,7 +258,9 @@ function ProxyResourceTargetsForm({
: target : target
); );
}); });
}, [sites]); },
[sites]
);
const openHealthCheckDialog = useCallback((target: LocalTarget) => { const openHealthCheckDialog = useCallback((target: LocalTarget) => {
setSelectedTargetForHealthCheck(target); setSelectedTargetForHealthCheck(target);
@@ -250,7 +268,6 @@ function ProxyResourceTargetsForm({
}, []); }, []);
const columns = useMemo((): ColumnDef<LocalTarget>[] => { const columns = useMemo((): ColumnDef<LocalTarget>[] => {
const priorityColumn: ColumnDef<LocalTarget> = { const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority", id: "priority",
header: () => ( header: () => (
@@ -581,7 +598,17 @@ function ProxyResourceTargetsForm({
actionsColumn actionsColumn
]; ];
} }
}, [isAdvancedMode, isHttp, sites, updateTarget, getDockerStateForSite, refreshContainersForSite, openHealthCheckDialog, removeTarget, t]); }, [
isAdvancedMode,
isHttp,
sites,
updateTarget,
getDockerStateForSite,
refreshContainersForSite,
openHealthCheckDialog,
removeTarget,
t
]);
function addNewTarget() { function addNewTarget() {
const isHttp = resource.http; const isHttp = resource.http;

View File

@@ -171,8 +171,7 @@ const DockerContainersTable: FC<{
...Object.values(container.networks) ...Object.values(container.networks)
.map((n) => n.ipAddress) .map((n) => n.ipAddress)
.filter(Boolean), .filter(Boolean),
...getExposedPorts(container).map((p) => p.toString()), ...getExposedPorts(container).map((p) => p.toString())
...Object.entries(container.labels).flat()
]; ];
return searchableFields.some((field) => return searchableFields.some((field) =>

View File

@@ -20,7 +20,7 @@ import {
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useEffect, useState } from "react"; import { useState, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox"; import CopyTextBox from "@app/components/CopyTextBox";
@@ -39,7 +39,8 @@ import { formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { ListResourcesResponse } from "@server/routers/resource"; import { useQuery } from "@tanstack/react-query";
import { orgQueries } from "@app/lib/queries";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -94,14 +95,22 @@ export default function CreateShareLinkForm({
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const t = useTranslations(); const t = useTranslations();
const [resources, setResources] = useState< const { data: allResources = [] } = useQuery(
{ orgQueries.resources({ orgId: org?.org.orgId ?? "" })
resourceId: number; );
name: string;
niceId: string; const resources = useMemo(
resourceUrl: string; () =>
}[] allResources
>([]); .filter((r) => r.http)
.map((r) => ({
resourceId: r.resourceId,
name: r.name,
niceId: r.niceId,
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
})),
[allResources]
);
const formSchema = z.object({ const formSchema = z.object({
resourceId: z.number({ message: t("shareErrorSelectResource") }), resourceId: z.number({ message: t("shareErrorSelectResource") }),
@@ -130,47 +139,6 @@ export default function CreateShareLinkForm({
} }
}); });
useEffect(() => {
if (!open) {
return;
}
async function fetchResources() {
const res = await api
.get<
AxiosResponse<ListResourcesResponse>
>(`/org/${org?.org.orgId}/resources`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t("shareErrorFetchResource"),
description: formatAxiosError(
e,
t("shareErrorFetchResourceDescription")
)
});
});
if (res?.status === 200) {
setResources(
res.data.data.resources
.filter((r) => {
return r.http;
})
.map((r) => ({
resourceId: r.resourceId,
name: r.name,
niceId: r.niceId,
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
}))
);
}
}
fetchResources();
}, [open]);
async function onSubmit(values: z.infer<typeof formSchema>) { async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true); setLoading(true);

View File

@@ -1230,8 +1230,12 @@ export function InternalResourceForm({
)} )}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<StrategySelect<"site" | "remote"> <StrategySelect<
value={field.value ?? undefined} "site" | "remote"
>
value={
field.value ?? undefined
}
options={[ options={[
{ {
id: "site", id: "site",
@@ -1241,7 +1245,8 @@ export function InternalResourceForm({
description: t( description: t(
"internalResourceAuthDaemonSiteDescription" "internalResourceAuthDaemonSiteDescription"
), ),
disabled: sshSectionDisabled disabled:
sshSectionDisabled
}, },
{ {
id: "remote", id: "remote",
@@ -1251,11 +1256,13 @@ export function InternalResourceForm({
description: t( description: t(
"internalResourceAuthDaemonRemoteDescription" "internalResourceAuthDaemonRemoteDescription"
), ),
disabled: sshSectionDisabled disabled:
sshSectionDisabled
} }
]} ]}
onChange={(v) => { onChange={(v) => {
if (sshSectionDisabled) return; if (sshSectionDisabled)
return;
field.onChange(v); field.onChange(v);
if (v === "site") { if (v === "site") {
form.setValue( form.setValue(
@@ -1289,10 +1296,17 @@ export function InternalResourceForm({
max={65535} max={65535}
placeholder="22123" placeholder="22123"
{...field} {...field}
disabled={sshSectionDisabled} disabled={
value={field.value ?? ""} sshSectionDisabled
}
value={
field.value ?? ""
}
onChange={(e) => { onChange={(e) => {
if (sshSectionDisabled) return; if (
sshSectionDisabled
)
return;
const v = const v =
e.target.value; e.target.value;
if (v === "") { if (v === "") {
@@ -1301,12 +1315,12 @@ export function InternalResourceForm({
); );
return; return;
} }
const num = parseInt( const num =
v, parseInt(v, 10);
10
);
field.onChange( field.onChange(
Number.isNaN(num) Number.isNaN(
num
)
? null ? null
: num : num
); );

View File

@@ -101,6 +101,7 @@ export function NewtSiteInstallCommands({
`helm install newt fossorial/newt \\ `helm install newt fossorial/newt \\
--create-namespace \\ --create-namespace \\
--set newtInstances[0].name="main-tunnel" \\ --set newtInstances[0].name="main-tunnel" \\
--set newtInstances[0].enabled=true \\
--set-string newtInstances[0].auth.keys.endpointKey="${endpoint}" \\ --set-string newtInstances[0].auth.keys.endpointKey="${endpoint}" \\
--set-string newtInstances[0].auth.keys.idKey="${id}" \\ --set-string newtInstances[0].auth.keys.idKey="${id}" \\
--set-string newtInstances[0].auth.keys.secretKey="${secret}"` --set-string newtInstances[0].auth.keys.secretKey="${secret}"`
@@ -186,9 +187,7 @@ WantedBy=default.target`
/> />
<div className="pt-4"> <div className="pt-4">
<p className="font-bold mb-3"> <p className="font-bold mb-3">{t("siteConfiguration")}</p>
{t("siteConfiguration")}
</p>
<div className="flex items-center space-x-2 mb-2"> <div className="flex items-center space-x-2 mb-2">
<CheckboxWithLabel <CheckboxWithLabel
id="acceptClients" id="acceptClients"
@@ -211,19 +210,34 @@ WantedBy=default.target`
<div className="pt-4"> <div className="pt-4">
<p className="font-bold mb-3">{t("commands")}</p> <p className="font-bold mb-3">{t("commands")}</p>
{platform === "kubernetes" && (
<p className="text-sm text-muted-foreground mb-3">
For more and up to date Kubernetes installation
information, see{" "}
<a
href="https://docs.pangolin.net/manage/sites/install-kubernetes"
target="_blank"
rel="noreferrer"
className="underline"
>
docs.pangolin.net/manage/sites/install-kubernetes
</a>
.
</p>
)}
<div className="mt-2 space-y-3"> <div className="mt-2 space-y-3">
{commands.map((item, index) => { {commands.map((item, index) => {
const commandText = const commandText =
typeof item === "string" typeof item === "string" ? item : item.command;
? item
: item.command;
const title = const title =
typeof item === "string" typeof item === "string"
? undefined ? undefined
: item.title; : item.title;
const key = `${title ?? ""}::${commandText}`;
return ( return (
<div key={index}> <div key={key}>
{title && ( {title && (
<p className="text-sm font-medium mb-1.5"> <p className="text-sm font-medium mb-1.5">
{title} {title}

View File

@@ -4,7 +4,8 @@ import type { ListClientsResponse } from "@server/routers/client";
import type { ListDomainsResponse } from "@server/routers/domain"; import type { ListDomainsResponse } from "@server/routers/domain";
import type { import type {
GetResourceWhitelistResponse, GetResourceWhitelistResponse,
ListResourceNamesResponse ListResourceNamesResponse,
ListResourcesResponse
} from "@server/routers/resource"; } from "@server/routers/resource";
import type { ListRolesResponse } from "@server/routers/role"; import type { ListRolesResponse } from "@server/routers/role";
import type { ListSitesResponse } from "@server/routers/site"; import type { ListSitesResponse } from "@server/routers/site";
@@ -90,23 +91,13 @@ export const productUpdatesQueries = {
}) })
}; };
export const clientFilterSchema = z.object({
pageSize: z.int().prefault(1000).optional()
});
export const orgQueries = { export const orgQueries = {
clients: ({ clients: ({ orgId }: { orgId: string }) =>
orgId,
filters
}: {
orgId: string;
filters?: z.infer<typeof clientFilterSchema>;
}) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "CLIENTS", filters] as const, queryKey: ["ORG", orgId, "CLIENTS"] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({ const sp = new URLSearchParams({
pageSize: (filters?.pageSize ?? 1000).toString() pageSize: "10000"
}); });
const res = await meta!.api.get< const res = await meta!.api.get<
@@ -143,9 +134,13 @@ export const orgQueries = {
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "SITES"] as const, queryKey: ["ORG", orgId, "SITES"] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: "10000"
});
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListSitesResponse> AxiosResponse<ListSitesResponse>
>(`/org/${orgId}/sites`, { signal }); >(`/org/${orgId}/sites?${sp.toString()}`, { signal });
return res.data.data.sites; return res.data.data.sites;
} }
}), }),
@@ -182,6 +177,22 @@ export const orgQueries = {
); );
return res.data.data.idps; return res.data.data.idps;
} }
}),
resources: ({ orgId }: { orgId: string }) =>
queryOptions({
queryKey: ["ORG", orgId, "RESOURCES"] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: "10000"
});
const res = await meta!.api.get<
AxiosResponse<ListResourcesResponse>
>(`/org/${orgId}/resources?${sp.toString()}`, { signal });
return res.data.data.resources;
}
}) })
}; };