Compare commits

..

34 Commits

Author SHA1 Message Date
Owen
35fed74e49 Merge branch 'dev' into msg-opt 2026-03-02 18:52:35 -08:00
Owen
64bae5b142 Merge branch 'main' into dev 2026-03-02 18:52:20 -08:00
Owen
6cf1b9b010 Support improved targets msg v2 2026-03-02 18:51:48 -08:00
Owen
dae169540b Fix defaults for orgs 2026-03-02 16:49:17 -08:00
Owen
19f9dda490 Add comment about not needing exit node 2026-03-02 16:28:01 -08:00
Owen Schwartz
cdf79edb00 Merge pull request #2570 from Fizza-Mukhtar/fix/mixed-target-failover-2448
fix: local targets ignored when newt site is unhealthy (mixed target failover)
2026-03-01 15:58:25 -08:00
Milo Schwartz
280cbb6e22 Merge pull request #2553 from LaurenceJJones/explore/static-org-dropdown
enhance(sidebar): make mobile org selector sticky
2026-03-01 11:14:16 -08:00
miloschwartz
c20babcb53 fix org selector spacing on mobile 2026-03-01 11:13:49 -08:00
Owen Schwartz
768eebe2cd Merge pull request #2432 from ChanningHe/feat-integration-api-domain-crud
feat(integration): add domain CRUD endpoints to integration API
2026-03-01 11:12:05 -08:00
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
e63a6e9b77 fix: treat local and wireguard sites as online for failover 2026-03-01 07:56:47 -08:00
Fizza-Mukhtar
7ce589c4f2 fix: exclude labels from container search to prevent false positives 2026-03-01 06:50:03 -08:00
Fizza-Mukhtar
f36cf06e26 fix: fallback to local targets when newt targets are unhealthy 2026-03-01 01:43:15 -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
Owen Schwartz
b0cb0e5a99 Merge pull request #2559 from fosrl/dev
1.16.1
2026-02-27 12:40:23 -08:00
miloschwartz
8347203bbe add sort to name col 2026-02-27 12:39:26 -08:00
miloschwartz
4aa1186aed fix machine client pagination 2026-02-27 11:59:55 -08:00
Owen
eed87af61d Use ecr base to build 2026-02-26 21:43:14 -08:00
Owen
daeea8e7ea Add alises to quieries
Fixes #2556
2026-02-26 21:37:47 -08:00
Owen
0d63a15715 Merge branch 'main' into dev 2026-02-26 20:14:41 -08:00
miloschwartz
fa2e229ada support authPath in device login 2026-02-26 14:59:34 -08:00
Laurence
81c1a1da9c enhance(sidebar): make mobile org selector sticky
Make org selector sticky on mobile sidebar

  Move OrgSelector outside the scrollable container so it stays fixed
  at the top while menu items scroll, matching the desktop sidebar
  behavior introduced in 9b2c0d0b.
2026-02-26 15:45:41 +00:00
ChanningHe
52f26396ac feat(integration): add domain CRUD endpoints to integration API 2026-02-26 08:44:55 +09:00
35 changed files with 1047 additions and 391 deletions

View File

@@ -1,4 +1,5 @@
FROM node:24-slim AS base
# FROM node:24-slim AS base
FROM public.ecr.aws/docker/library/node:24-slim AS base
WORKDIR /app
@@ -31,7 +32,8 @@ FROM base AS builder
RUN npm ci --omit=dev
FROM node:24-slim AS runner
# FROM node:24-slim AS runner
FROM public.ecr.aws/docker/library/node:24-slim AS runner
WORKDIR /app

View File

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

View File

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

View File

@@ -1102,6 +1102,12 @@
"actionGetUser": "Get User",
"actionGetOrgUser": "Get Organization User",
"actionListOrgDomains": "List Organization Domains",
"actionGetDomain": "Get Domain",
"actionCreateOrgDomain": "Create Domain",
"actionUpdateOrgDomain": "Update Domain",
"actionDeleteOrgDomain": "Delete Domain",
"actionGetDNSRecords": "Get DNS Records",
"actionRestartOrgDomain": "Restart Domain",
"actionCreateSite": "Create Site",
"actionDeleteSite": "Delete Site",
"actionGetSite": "Get Site",
@@ -1670,10 +1676,10 @@
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
"sshSudo": "Allow sudo",
"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",
"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",
"expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",

View File

@@ -571,6 +571,129 @@ export function generateSubnetProxyTargets(
return targets;
}
export type SubnetProxyTargetV2 = {
sourcePrefixes: string[]; // must be cidrs
destPrefix: string; // must be a cidr
disableIcmp?: boolean;
rewriteTo?: string; // must be a cidr
portRange?: {
min: number;
max: number;
protocol: "tcp" | "udp";
}[];
};
export function generateSubnetProxyTargetV2(
siteResource: SiteResource,
clients: {
clientId: number;
pubKey: string | null;
subnet: string | null;
}[]
): SubnetProxyTargetV2 | undefined {
if (clients.length === 0) {
logger.debug(
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
);
return;
}
let target: SubnetProxyTargetV2 | null = null;
const portRange = [
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
...parsePortRangeString(siteResource.udpPortRangeString, "udp")
];
const disableIcmp = siteResource.disableIcmp ?? false;
if (siteResource.mode == "host") {
let destination = siteResource.destination;
// check if this is a valid ip
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
if (ipSchema.safeParse(destination).success) {
destination = `${destination}/32`;
target = {
sourcePrefixes: [],
destPrefix: destination,
portRange,
disableIcmp
};
}
if (siteResource.alias && siteResource.aliasAddress) {
// also push a match for the alias address
target = {
sourcePrefixes: [],
destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination,
portRange,
disableIcmp
};
}
} else if (siteResource.mode == "cidr") {
target = {
sourcePrefixes: [],
destPrefix: siteResource.destination,
portRange,
disableIcmp
};
}
if (!target) {
return;
}
for (const clientSite of clients) {
if (!clientSite.subnet) {
logger.debug(
`Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.`
);
continue;
}
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
// add client prefix to source prefixes
target.sourcePrefixes.push(clientPrefix);
}
// print a nice representation of the targets
// logger.debug(
// `Generated subnet proxy targets for: ${JSON.stringify(targets, null, 2)}`
// );
return target;
}
/**
* Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1)
* by expanding each source prefix into its own target entry.
* @param targetV2 - The v2 target to convert
* @returns Array of v1 SubnetProxyTarget objects
*/
export function convertSubnetProxyTargetsV2ToV1(
targetsV2: SubnetProxyTargetV2[]
): SubnetProxyTarget[] {
return targetsV2.flatMap((targetV2) =>
targetV2.sourcePrefixes.map((sourcePrefix) => ({
sourcePrefix,
destPrefix: targetV2.destPrefix,
...(targetV2.disableIcmp !== undefined && {
disableIcmp: targetV2.disableIcmp
}),
...(targetV2.rewriteTo !== undefined && {
rewriteTo: targetV2.rewriteTo
}),
...(targetV2.portRange !== undefined && {
portRange: targetV2.portRange
})
}))
);
}
// Custom schema for validating port range strings
// Format: "80,443,8000-9000" or "*" for all ports, or empty string
export const portRangeStringSchema = z

View File

@@ -302,8 +302,8 @@ export const configSchema = z
.optional()
.default({
block_size: 24,
subnet_group: "100.90.128.0/24",
utility_subnet_group: "100.96.128.0/24"
subnet_group: "100.90.128.0/20",
utility_subnet_group: "100.96.128.0/20"
}),
rate_limits: z
.object({

View File

@@ -32,7 +32,7 @@ import logger from "@server/logger";
import {
generateAliasConfig,
generateRemoteSubnets,
generateSubnetProxyTargets,
generateSubnetProxyTargetV2,
parseEndpoint,
formatEndpoint
} from "@server/lib/ip";
@@ -659,17 +659,14 @@ async function handleSubnetProxyTargetUpdates(
);
if (addedClients.length > 0) {
const targetsToAdd = generateSubnetProxyTargets(
const targetToAdd = generateSubnetProxyTargetV2(
siteResource,
addedClients
);
if (targetsToAdd.length > 0) {
logger.info(
`Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}`
);
if (targetToAdd) {
proxyJobs.push(
addSubnetProxyTargets(newt.newtId, targetsToAdd)
addSubnetProxyTargets(newt.newtId, [targetToAdd])
);
}
@@ -695,17 +692,14 @@ async function handleSubnetProxyTargetUpdates(
);
if (removedClients.length > 0) {
const targetsToRemove = generateSubnetProxyTargets(
const targetToRemove = generateSubnetProxyTargetV2(
siteResource,
removedClients
);
if (targetsToRemove.length > 0) {
logger.info(
`Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}`
);
if (targetToRemove) {
proxyJobs.push(
removeSubnetProxyTargets(newt.newtId, targetsToRemove)
removeSubnetProxyTargets(newt.newtId, [targetToRemove])
);
}
@@ -1159,7 +1153,7 @@ async function handleMessagesForClientResources(
}
for (const resource of resources) {
const targets = generateSubnetProxyTargets(resource, [
const target = generateSubnetProxyTargetV2(resource, [
{
clientId: client.clientId,
pubKey: client.pubKey,
@@ -1167,8 +1161,8 @@ async function handleMessagesForClientResources(
}
]);
if (targets.length > 0) {
proxyJobs.push(addSubnetProxyTargets(newt.newtId, targets));
if (target) {
proxyJobs.push(addSubnetProxyTargets(newt.newtId, [target]));
}
try {
@@ -1230,7 +1224,7 @@ async function handleMessagesForClientResources(
}
for (const resource of resources) {
const targets = generateSubnetProxyTargets(resource, [
const target = generateSubnetProxyTargetV2(resource, [
{
clientId: client.clientId,
pubKey: client.pubKey,
@@ -1238,9 +1232,9 @@ async function handleMessagesForClientResources(
}
]);
if (targets.length > 0) {
if (target) {
proxyJobs.push(
removeSubnetProxyTargets(newt.newtId, targets)
removeSubnetProxyTargets(newt.newtId, [target])
);
}

View File

@@ -477,7 +477,10 @@ export async function getTraefikConfig(
// TODO: HOW TO HANDLE ^^^^^^ BETTER
const anySitesOnline = targets.some(
(target) => target.site.online
(target) =>
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
);
return (
@@ -490,7 +493,7 @@ export async function getTraefikConfig(
if (target.health == "unhealthy") {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;
@@ -605,7 +608,10 @@ export async function getTraefikConfig(
servers: (() => {
// Check if any sites are online
const anySitesOnline = targets.some(
(target) => target.site.online
(target) =>
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
);
return targets
@@ -613,7 +619,7 @@ export async function getTraefikConfig(
if (!target.enabled) {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;

View File

@@ -14,3 +14,4 @@ export * from "./verifyApiKeyApiKeyAccess";
export * from "./verifyApiKeyClientAccess";
export * from "./verifyApiKeySiteResourceAccess";
export * from "./verifyApiKeyIdpAccess";
export * from "./verifyApiKeyDomainAccess";

View File

@@ -0,0 +1,90 @@
import { Request, Response, NextFunction } from "express";
import { db, domains, orgDomains, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export async function verifyApiKeyDomainAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const apiKey = req.apiKey;
const domainId =
req.params.domainId || req.body.domainId || req.query.domainId;
const orgId = req.params.orgId;
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
if (!domainId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid domain ID")
);
}
if (apiKey.isRoot) {
// Root keys can access any domain in any org
return next();
}
// Verify domain exists and belongs to the organization
const [domain] = await db
.select()
.from(domains)
.innerJoin(orgDomains, eq(orgDomains.domainId, domains.domainId))
.where(
and(
eq(orgDomains.domainId, domainId),
eq(orgDomains.orgId, orgId)
)
)
.limit(1);
if (!domain) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Domain with ID ${domainId} not found in organization ${orgId}`
)
);
}
// Verify the API key has access to this organization
if (!req.apiKeyOrg) {
const apiKeyOrgRes = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, orgId)
)
)
.limit(1);
req.apiKeyOrg = apiKeyOrgRes[0];
}
if (!req.apiKeyOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying domain access"
)
);
}
}

View File

@@ -665,7 +665,10 @@ export async function getTraefikConfig(
// TODO: HOW TO HANDLE ^^^^^^ BETTER
const anySitesOnline = targets.some(
(target) => target.site.online
(target) =>
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
);
return (
@@ -793,7 +796,10 @@ export async function getTraefikConfig(
servers: (() => {
// Check if any sites are online
const anySitesOnline = targets.some(
(target) => target.site.online
(target) =>
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
);
return targets

View File

@@ -119,12 +119,12 @@ const listClientsSchema = z.object({
}),
query: z.string().optional(),
sort_by: z
.enum(["megabytesIn", "megabytesOut"])
.enum(["name", "megabytesIn", "megabytesOut"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["megabytesIn", "megabytesOut"],
enum: ["name", "megabytesIn", "megabytesOut"],
description: "Field to sort by"
}),
order: z
@@ -363,14 +363,14 @@ export async function listClients(
const countQuery = db.$count(baseQuery.as("filtered_clients"));
const listMachinesQuery = baseQuery
.limit(page)
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(
sort_by
? order === "asc"
? asc(clients[sort_by])
: desc(clients[sort_by])
: asc(clients.clientId)
: asc(clients.name)
);
const [clientsList, totalCount] = await Promise.all([

View File

@@ -1,8 +1,15 @@
import { sendToClient } from "#dynamic/routers/ws";
import { db, olms, Transaction } from "@server/db";
import { Alias, SubnetProxyTarget } from "@server/lib/ip";
import { S } from "@faker-js/faker/dist/airline-Dz1uGqgJ";
import { db, newts, olms, Transaction } from "@server/db";
import {
Alias,
convertSubnetProxyTargetsV2ToV1,
SubnetProxyTarget,
SubnetProxyTargetV2
} from "@server/lib/ip";
import logger from "@server/logger";
import { eq } from "drizzle-orm";
import semver from "semver";
const BATCH_SIZE = 50;
const BATCH_DELAY_MS = 50;
@@ -19,57 +26,149 @@ function chunkArray<T>(array: T[], size: number): T[][] {
return chunks;
}
export async function addTargets(newtId: string, targets: SubnetProxyTarget[]) {
const batches = chunkArray(targets, BATCH_SIZE);
const NEWT_V2_TARGETS_VERSION = ">=1.11.0";
export async function convertTargetsIfNessicary(
newtId: string,
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[]
) {
// get the newt
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.newtId, newtId));
if (!newt) {
throw new Error(`No newt found for id: ${newtId}`);
}
// check the semver
if (
newt.version &&
!semver.satisfies(newt.version, NEWT_V2_TARGETS_VERSION)
) {
logger.debug(
`addTargets Newt version ${newt.version} does not support targets v2 falling back`
);
targets = convertSubnetProxyTargetsV2ToV1(
targets as SubnetProxyTargetV2[]
);
}
return targets;
}
export async function addTargets(
newtId: string,
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[]
) {
targets = await convertTargetsIfNessicary(newtId, targets);
const batches = chunkArray<SubnetProxyTarget | SubnetProxyTargetV2>(
targets,
BATCH_SIZE
);
for (let i = 0; i < batches.length; i++) {
if (i > 0) {
await sleep(BATCH_DELAY_MS);
}
await sendToClient(newtId, {
type: `newt/wg/targets/add`,
data: batches[i]
}, { incrementConfigVersion: true });
await sendToClient(
newtId,
{
type: `newt/wg/targets/add`,
data: batches[i]
},
{ incrementConfigVersion: true }
);
}
}
export async function removeTargets(
newtId: string,
targets: SubnetProxyTarget[]
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[]
) {
const batches = chunkArray(targets, BATCH_SIZE);
targets = await convertTargetsIfNessicary(newtId, targets);
const batches = chunkArray<SubnetProxyTarget | SubnetProxyTargetV2>(
targets,
BATCH_SIZE
);
for (let i = 0; i < batches.length; i++) {
if (i > 0) {
await sleep(BATCH_DELAY_MS);
}
await sendToClient(newtId, {
type: `newt/wg/targets/remove`,
data: batches[i]
},{ incrementConfigVersion: true });
await sendToClient(
newtId,
{
type: `newt/wg/targets/remove`,
data: batches[i]
},
{ incrementConfigVersion: true }
);
}
}
export async function updateTargets(
newtId: string,
targets: {
oldTargets: SubnetProxyTarget[];
newTargets: SubnetProxyTarget[];
oldTargets: SubnetProxyTarget[] | SubnetProxyTargetV2[];
newTargets: SubnetProxyTarget[] | SubnetProxyTargetV2[];
}
) {
const oldBatches = chunkArray(targets.oldTargets, BATCH_SIZE);
const newBatches = chunkArray(targets.newTargets, BATCH_SIZE);
// get the newt
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.newtId, newtId));
if (!newt) {
logger.error(`addTargetsL No newt found for id: ${newtId}`);
return;
}
// check the semver
if (
newt.version &&
!semver.satisfies(newt.version, NEWT_V2_TARGETS_VERSION)
) {
logger.debug(
`addTargets Newt version ${newt.version} does not support targets v2 falling back`
);
targets = {
oldTargets: convertSubnetProxyTargetsV2ToV1(
targets.oldTargets as SubnetProxyTargetV2[]
),
newTargets: convertSubnetProxyTargetsV2ToV1(
targets.newTargets as SubnetProxyTargetV2[]
)
};
}
const oldBatches = chunkArray<SubnetProxyTarget | SubnetProxyTargetV2>(
targets.oldTargets,
BATCH_SIZE
);
const newBatches = chunkArray<SubnetProxyTarget | SubnetProxyTargetV2>(
targets.newTargets,
BATCH_SIZE
);
const maxBatches = Math.max(oldBatches.length, newBatches.length);
for (let i = 0; i < maxBatches; i++) {
if (i > 0) {
await sleep(BATCH_DELAY_MS);
}
await sendToClient(newtId, {
type: `newt/wg/targets/update`,
data: {
oldTargets: oldBatches[i] || [],
newTargets: newBatches[i] || []
}
}, { incrementConfigVersion: true }).catch((error) => {
await sendToClient(
newtId,
{
type: `newt/wg/targets/update`,
data: {
oldTargets: oldBatches[i] || [],
newTargets: newBatches[i] || []
}
},
{ incrementConfigVersion: true }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
}
@@ -94,14 +193,18 @@ export async function addPeerData(
olmId = olm.olmId;
}
await sendToClient(olmId, {
type: `olm/wg/peer/data/add`,
data: {
siteId: siteId,
remoteSubnets: remoteSubnets,
aliases: aliases
}
}, { incrementConfigVersion: true }).catch((error) => {
await sendToClient(
olmId,
{
type: `olm/wg/peer/data/add`,
data: {
siteId: siteId,
remoteSubnets: remoteSubnets,
aliases: aliases
}
},
{ incrementConfigVersion: true }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
}
@@ -125,14 +228,18 @@ export async function removePeerData(
olmId = olm.olmId;
}
await sendToClient(olmId, {
type: `olm/wg/peer/data/remove`,
data: {
siteId: siteId,
remoteSubnets: remoteSubnets,
aliases: aliases
}
}, { incrementConfigVersion: true }).catch((error) => {
await sendToClient(
olmId,
{
type: `olm/wg/peer/data/remove`,
data: {
siteId: siteId,
remoteSubnets: remoteSubnets,
aliases: aliases
}
},
{ incrementConfigVersion: true }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
}
@@ -166,14 +273,18 @@ export async function updatePeerData(
olmId = olm.olmId;
}
await sendToClient(olmId, {
type: `olm/wg/peer/data/update`,
data: {
siteId: siteId,
...remoteSubnets,
...aliases
}
}, { incrementConfigVersion: true }).catch((error) => {
await sendToClient(
olmId,
{
type: `olm/wg/peer/data/update`,
data: {
siteId: siteId,
...remoteSubnets,
...aliases
}
},
{ incrementConfigVersion: true }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
}

View File

@@ -27,7 +27,8 @@ import {
verifyApiKeyClientAccess,
verifyApiKeySiteResourceAccess,
verifyApiKeySetResourceClients,
verifyLimits
verifyLimits,
verifyApiKeyDomainAccess
} from "@server/middlewares";
import HttpCode from "@server/types/HttpCode";
import { Router } from "express";
@@ -347,6 +348,56 @@ authenticated.get(
domain.listDomains
);
authenticated.get(
"/org/:orgId/domain/:domainId",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.getDomain),
domain.getDomain
);
authenticated.put(
"/org/:orgId/domain",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.createOrgDomain),
logActionAudit(ActionsEnum.createOrgDomain),
domain.createOrgDomain
);
authenticated.patch(
"/org/:orgId/domain/:domainId",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.updateOrgDomain),
domain.updateOrgDomain
);
authenticated.delete(
"/org/:orgId/domain/:domainId",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.deleteOrgDomain),
logActionAudit(ActionsEnum.deleteOrgDomain),
domain.deleteAccountDomain
);
authenticated.get(
"/org/:orgId/domain/:domainId/dns-records",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.getDNSRecords),
domain.getDNSRecords
);
authenticated.post(
"/org/:orgId/domain/:domainId/restart",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.restartOrgDomain),
logActionAudit(ActionsEnum.restartOrgDomain),
domain.restartOrgDomain
);
authenticated.get(
"/org/:orgId/invitations",
verifyApiKeyOrgAccess,

View File

@@ -1,9 +1,23 @@
import { clients, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, db, ExitNode, resources, Site, siteResources, targetHealthCheck, targets } from "@server/db";
import {
clients,
clientSiteResourcesAssociationsCache,
clientSitesAssociationsCache,
db,
ExitNode,
resources,
Site,
siteResources,
targetHealthCheck,
targets
} from "@server/db";
import logger from "@server/logger";
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
import { eq, and } from "drizzle-orm";
import config from "@server/lib/config";
import { generateSubnetProxyTargets, SubnetProxyTarget } from "@server/lib/ip";
import {
generateSubnetProxyTargetV2,
SubnetProxyTargetV2
} from "@server/lib/ip";
export async function buildClientConfigurationForNewtClient(
site: Site,
@@ -126,7 +140,7 @@ export async function buildClientConfigurationForNewtClient(
.from(siteResources)
.where(eq(siteResources.siteId, siteId));
const targetsToSend: SubnetProxyTarget[] = [];
const targetsToSend: SubnetProxyTargetV2[] = [];
for (const resource of allSiteResources) {
// Get clients associated with this specific resource
@@ -151,12 +165,14 @@ export async function buildClientConfigurationForNewtClient(
)
);
const resourceTargets = generateSubnetProxyTargets(
const resourceTarget = generateSubnetProxyTargetV2(
resource,
resourceClients
);
targetsToSend.push(...resourceTargets);
if (resourceTarget) {
targetsToSend.push(resourceTarget);
}
}
return {

View File

@@ -6,6 +6,7 @@ import { db, ExitNode, exitNodes, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm";
import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
import { convertTargetsIfNessicary } from "../client/targets";
const inputSchema = z.object({
publicKey: z.string(),
@@ -126,13 +127,15 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
exitNode
);
const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets);
return {
message: {
type: "newt/wg/receive-config",
data: {
ipAddress: site.address,
peers,
targets
targets: targetsToSend
}
},
broadcast: false,

View File

@@ -19,6 +19,7 @@ import {
and,
asc,
count,
desc,
eq,
inArray,
isNull,
@@ -63,6 +64,26 @@ const listResourcesSchema = z.object({
description: "Page number to retrieve"
}),
query: z.string().optional(),
sort_by: z
.enum(["name"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["name"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
}),
enabled: z
.enum(["true", "false"])
.transform((v) => v === "true")
@@ -229,8 +250,16 @@ export async function listResources(
)
);
}
const { page, pageSize, authState, enabled, query, healthStatus } =
parsedQuery.data;
const {
page,
pageSize,
authState,
enabled,
query,
healthStatus,
sort_by,
order
} = parsedQuery.data;
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -395,7 +424,13 @@ export async function listResources(
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(resources.resourceId)),
.orderBy(
sort_by
? order === "asc"
? asc(resources[sort_by])
: desc(resources[sort_by])
: asc(resources.name)
),
countQuery
]);

View File

@@ -292,7 +292,7 @@ export async function createSite(
if (type == "newt") {
[newSite] = await trx
.insert(sites)
.values({
.values({ // NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT
orgId,
name,
niceId,

View File

@@ -108,12 +108,12 @@ const listSitesSchema = z.object({
}),
query: z.string().optional(),
sort_by: z
.enum(["megabytesIn", "megabytesOut"])
.enum(["name", "megabytesIn", "megabytesOut"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["megabytesIn", "megabytesOut"],
enum: ["name", "megabytesIn", "megabytesOut"],
description: "Field to sort by"
}),
order: z
@@ -278,7 +278,7 @@ export async function listSites(
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(
querySitesBase().where(and(...conditions))
querySitesBase().where(and(...conditions)).as("filtered_sites")
);
const siteListQuery = baseQuery
@@ -289,7 +289,7 @@ export async function listSites(
? order === "asc"
? asc(sites[sort_by])
: desc(sites[sort_by])
: asc(sites.siteId)
: asc(sites.name)
);
const [totalCount, rows] = await Promise.all([

View File

@@ -4,7 +4,7 @@ import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import type { PaginatedResponse } from "@server/types/Pagination";
import { and, asc, eq, like, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -48,6 +48,26 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
type: "string",
enum: ["host", "cidr"],
description: "Filter site resources by mode"
}),
sort_by: z
.enum(["name"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["name"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
})
});
@@ -131,7 +151,8 @@ export async function listAllSiteResourcesByOrg(
}
const { orgId } = parsedParams.data;
const { page, pageSize, query, mode } = parsedQuery.data;
const { page, pageSize, query, mode, sort_by, order } =
parsedQuery.data;
const conditions = [and(eq(siteResources.orgId, orgId))];
if (query) {
@@ -172,14 +193,20 @@ export async function listAllSiteResourcesByOrg(
const baseQuery = querySiteResourcesBase().where(and(...conditions));
const countQuery = db.$count(
querySiteResourcesBase().where(and(...conditions))
querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources")
);
const [siteResourcesList, totalCount] = await Promise.all([
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(siteResources.siteResourceId)),
.orderBy(
sort_by
? order === "asc"
? asc(siteResources[sort_by])
: desc(siteResources[sort_by])
: asc(siteResources.name)
),
countQuery
]);

View File

@@ -5,7 +5,7 @@ import { siteResources, sites, SiteResource } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { eq, and } from "drizzle-orm";
import { and, asc, desc, eq } from "drizzle-orm";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -27,7 +27,27 @@ const listSiteResourcesQuerySchema = z.object({
.optional()
.default("0")
.transform(Number)
.pipe(z.int().nonnegative())
.pipe(z.int().nonnegative()),
sort_by: z
.enum(["name"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["name"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
})
});
export type ListSiteResourcesResponse = {
@@ -75,7 +95,7 @@ export async function listSiteResources(
}
const { siteId, orgId } = parsedParams.data;
const { limit, offset } = parsedQuery.data;
const { limit, offset, sort_by, order } = parsedQuery.data;
// Verify the site exists and belongs to the org
const site = await db
@@ -98,6 +118,13 @@ export async function listSiteResources(
eq(siteResources.orgId, orgId)
)
)
.orderBy(
sort_by
? order === "asc"
? asc(siteResources[sort_by])
: desc(siteResources[sort_by])
: asc(siteResources.name)
)
.limit(limit)
.offset(offset);

View File

@@ -24,7 +24,7 @@ import { updatePeerData, updateTargets } from "@server/routers/client/targets";
import {
generateAliasConfig,
generateRemoteSubnets,
generateSubnetProxyTargets,
generateSubnetProxyTargetV2,
isIpInCidr,
portRangeStringSchema
} from "@server/lib/ip";
@@ -608,18 +608,18 @@ export async function handleMessagingForUpdatedSiteResource(
// Only update targets on newt if destination changed
if (destinationChanged || portRangesChanged) {
const oldTargets = generateSubnetProxyTargets(
const oldTarget = generateSubnetProxyTargetV2(
existingSiteResource,
mergedAllClients
);
const newTargets = generateSubnetProxyTargets(
const newTarget = generateSubnetProxyTargetV2(
updatedSiteResource,
mergedAllClients
);
await updateTargets(newt.newtId, {
oldTargets: oldTargets,
newTargets: newTargets
oldTargets: [oldTarget],
newTargets: [newTarget]
});
}

View File

@@ -89,7 +89,14 @@ import {
} from "lucide-react";
import { useTranslations } from "next-intl";
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 { z } from "zod";
@@ -184,29 +191,35 @@ function ProxyResourceTargetsForm({
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
};
const refreshContainersForSite = useCallback(async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers();
const refreshContainersForSite = useCallback(
async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers();
setDockerStates((prev) => {
const newMap = new Map(prev);
const existingState = newMap.get(siteId);
if (existingState) {
newMap.set(siteId, { ...existingState, containers });
}
return newMap;
});
}, [api]);
setDockerStates((prev) => {
const newMap = new Map(prev);
const existingState = newMap.get(siteId);
if (existingState) {
newMap.set(siteId, { ...existingState, containers });
}
return newMap;
});
},
[api]
);
const getDockerStateForSite = useCallback((siteId: number): DockerState => {
return (
dockerStates.get(siteId) || {
isEnabled: false,
isAvailable: false,
containers: []
}
);
}, [dockerStates]);
const getDockerStateForSite = useCallback(
(siteId: number): DockerState => {
return (
dockerStates.get(siteId) || {
isEnabled: false,
isAvailable: false,
containers: []
}
);
},
[dockerStates]
);
const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
if (typeof window !== "undefined") {
@@ -220,7 +233,9 @@ function ProxyResourceTargetsForm({
const removeTarget = useCallback((targetId: number) => {
setTargets((prevTargets) => {
const targetToRemove = prevTargets.find((target) => target.targetId === targetId);
const targetToRemove = prevTargets.find(
(target) => target.targetId === targetId
);
if (targetToRemove && !targetToRemove.new) {
setTargetsToRemove((prev) => [...prev, targetId]);
}
@@ -228,21 +243,24 @@ function ProxyResourceTargetsForm({
});
}, []);
const updateTarget = useCallback((targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
const site = sites.find((site) => site.siteId === data.siteId);
return prevTargets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
);
});
}, [sites]);
const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
const site = sites.find((site) => site.siteId === data.siteId);
return prevTargets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
);
});
},
[sites]
);
const openHealthCheckDialog = useCallback((target: LocalTarget) => {
setSelectedTargetForHealthCheck(target);
@@ -250,7 +268,6 @@ function ProxyResourceTargetsForm({
}, []);
const columns = useMemo((): ColumnDef<LocalTarget>[] => {
const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority",
header: () => (
@@ -581,7 +598,17 @@ function ProxyResourceTargetsForm({
actionsColumn
];
}
}, [isAdvancedMode, isHttp, sites, updateTarget, getDockerStateForSite, refreshContainersForSite, openHealthCheckDialog, removeTarget, t]);
}, [
isAdvancedMode,
isHttp,
sites,
updateTarget,
getDockerStateForSite,
refreshContainersForSite,
openHealthCheckDialog,
removeTarget,
t
]);
function addNewTarget() {
const isHttp = resource.http;

View File

@@ -3,11 +3,12 @@ import { redirect } from "next/navigation";
import DeviceLoginForm from "@/components/DeviceLoginForm";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { cache } from "react";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export const dynamic = "force-dynamic";
type Props = {
searchParams: Promise<{ code?: string; user?: string }>;
searchParams: Promise<{ code?: string; user?: string; authPath?: string }>;
};
function deviceRedirectSearchParams(params: {
@@ -30,11 +31,11 @@ export default async function DeviceLoginPage({ searchParams }: Props) {
if (!user) {
const redirectDestination = `/auth/login/device${deviceRedirectSearchParams({ code, user: params.user })}`;
const loginUrl = new URL("/auth/login", "http://x");
const authPath = cleanRedirect(params.authPath || "/auth/login");
const loginUrl = new URL(authPath, "http://x");
loginUrl.searchParams.set("forceLogin", "true");
loginUrl.searchParams.set("redirect", redirectDestination);
if (defaultUser) loginUrl.searchParams.set("user", defaultUser);
console.log("loginUrl", loginUrl.pathname + loginUrl.search);
redirect(loginUrl.pathname + loginUrl.search);
}

View File

@@ -15,7 +15,15 @@ import { InfoPopup } from "@app/components/ui/info-popup";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { ArrowUpDown, ArrowUpRight, MoreHorizontal } from "lucide-react";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import {
ArrowDown01Icon,
ArrowUp10Icon,
ArrowUpDown,
ArrowUpRight,
ChevronsUpDownIcon,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -133,7 +141,26 @@ export default function ClientResourcesTable({
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => <span className="p-3">{t("name")}</span>
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
@@ -329,6 +356,14 @@ export default function ClientResourcesTable({
});
}
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());

View File

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

View File

@@ -20,7 +20,7 @@ import {
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
import { useState, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox";
@@ -39,7 +39,8 @@ import { formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { createApiClient } from "@app/lib/api";
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 {
Popover,
PopoverContent,
@@ -94,14 +95,22 @@ export default function CreateShareLinkForm({
const [isOpen, setIsOpen] = useState(false);
const t = useTranslations();
const [resources, setResources] = useState<
{
resourceId: number;
name: string;
niceId: string;
resourceUrl: string;
}[]
>([]);
const { data: allResources = [] } = useQuery(
orgQueries.resources({ orgId: org?.org.orgId ?? "" })
);
const resources = useMemo(
() =>
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({
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>) {
setLoading(true);

View File

@@ -1189,137 +1189,151 @@ export function InternalResourceForm({
{/* SSH Access tab */}
{!disableEnterpriseFeatures && mode !== "cidr" && (
<div className="space-y-4 mt-4">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<div className="mb-8">
<label className="font-medium block">
{t("internalResourceAuthDaemonStrategy")}
</label>
<div className="text-sm text-muted-foreground">
{t.rich(
"internalResourceAuthDaemonDescription",
{
docsLink: (chunks) => (
<a
href={
"https://docs.pangolin.net/manage/ssh#setup-choose-your-architecture"
}
target="_blank"
rel="noopener noreferrer"
className={
"text-primary inline-flex items-center gap-1"
}
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</a>
)
}
)}
<div className="space-y-4 mt-4">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<div className="mb-8">
<label className="font-medium block">
{t("internalResourceAuthDaemonStrategy")}
</label>
<div className="text-sm text-muted-foreground">
{t.rich(
"internalResourceAuthDaemonDescription",
{
docsLink: (chunks) => (
<a
href={
"https://docs.pangolin.net/manage/ssh#setup-choose-your-architecture"
}
target="_blank"
rel="noopener noreferrer"
className={
"text-primary inline-flex items-center gap-1"
}
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</a>
)
}
)}
</div>
</div>
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="authDaemonMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"internalResourceAuthDaemonStrategyLabel"
)}
</FormLabel>
<FormControl>
<StrategySelect<"site" | "remote">
value={field.value ?? undefined}
options={[
{
id: "site",
title: t(
"internalResourceAuthDaemonSite"
),
description: t(
"internalResourceAuthDaemonSiteDescription"
),
disabled: sshSectionDisabled
},
{
id: "remote",
title: t(
"internalResourceAuthDaemonRemote"
),
description: t(
"internalResourceAuthDaemonRemoteDescription"
),
disabled: sshSectionDisabled
}
]}
onChange={(v) => {
if (sshSectionDisabled) return;
field.onChange(v);
if (v === "site") {
form.setValue(
"authDaemonPort",
null
);
}
}}
cols={2}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{authDaemonMode === "remote" && (
<div className="space-y-4">
<FormField
control={form.control}
name="authDaemonPort"
name="authDaemonMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"internalResourceAuthDaemonPort"
"internalResourceAuthDaemonStrategyLabel"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
placeholder="22123"
{...field}
disabled={sshSectionDisabled}
value={field.value ?? ""}
onChange={(e) => {
if (sshSectionDisabled) return;
const v =
e.target.value;
if (v === "") {
field.onChange(
<StrategySelect<
"site" | "remote"
>
value={
field.value ?? undefined
}
options={[
{
id: "site",
title: t(
"internalResourceAuthDaemonSite"
),
description: t(
"internalResourceAuthDaemonSiteDescription"
),
disabled:
sshSectionDisabled
},
{
id: "remote",
title: t(
"internalResourceAuthDaemonRemote"
),
description: t(
"internalResourceAuthDaemonRemoteDescription"
),
disabled:
sshSectionDisabled
}
]}
onChange={(v) => {
if (sshSectionDisabled)
return;
field.onChange(v);
if (v === "site") {
form.setValue(
"authDaemonPort",
null
);
return;
}
const num = parseInt(
v,
10
);
field.onChange(
Number.isNaN(num)
? null
: num
);
}}
cols={2}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{authDaemonMode === "remote" && (
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"internalResourceAuthDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
placeholder="22123"
{...field}
disabled={
sshSectionDisabled
}
value={
field.value ?? ""
}
onChange={(e) => {
if (
sshSectionDisabled
)
return;
const v =
e.target.value;
if (v === "") {
field.onChange(
null
);
return;
}
const num =
parseInt(v, 10);
field.onChange(
Number.isNaN(
num
)
? null
: num
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</div>
</div>
)}
</HorizontalTabs>
</form>

View File

@@ -69,15 +69,16 @@ export function LayoutMobileMenu({
<SheetDescription className="sr-only">
{t("navbarDescription")}
</SheetDescription>
<div className="flex-1 overflow-y-auto relative">
<div className="px-1">
<div className="w-full border-b border-border">
<div className="px-1 shrink-0">
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
</div>
<div className="w-full border-b border-border" />
<div className="px-3 pt-3">
</div>
<div className="flex-1 overflow-y-auto relative">
<div className="px-3">
{!isAdminPage &&
user.serverAdmin && (
<div className="mb-1">

View File

@@ -204,7 +204,26 @@ export default function MachineClientsTable({
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => <span className="px-3">{t("name")}</span>,
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
onClick={() => toggleSort("name")}
className="px-3"
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
return (

View File

@@ -31,7 +31,6 @@ function getActionsCategories(root: boolean) {
[t("actionListInvitations")]: "listInvitations",
[t("actionRemoveUser")]: "removeUser",
[t("actionListUsers")]: "listUsers",
[t("actionListOrgDomains")]: "listOrgDomains",
[t("updateOrgUser")]: "updateOrgUser",
[t("createOrgUser")]: "createOrgUser",
[t("actionApplyBlueprint")]: "applyBlueprint",
@@ -39,6 +38,16 @@ function getActionsCategories(root: boolean) {
[t("actionGetBlueprint")]: "getBlueprint"
},
Domain: {
[t("actionListOrgDomains")]: "listOrgDomains",
[t("actionGetDomain")]: "getDomain",
[t("actionCreateOrgDomain")]: "createOrgDomain",
[t("actionUpdateOrgDomain")]: "updateOrgDomain",
[t("actionDeleteOrgDomain")]: "deleteOrgDomain",
[t("actionGetDNSRecords")]: "getDNSRecords",
[t("actionRestartOrgDomain")]: "restartOrgDomain"
},
Site: {
[t("actionCreateSite")]: "createSite",
[t("actionDeleteSite")]: "deleteSite",

View File

@@ -14,15 +14,19 @@ import { InfoPopup } from "@app/components/ui/info-popup";
import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { UpdateResourceResponse } from "@server/routers/resource";
import type { PaginationState } from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import {
ArrowDown01Icon,
ArrowRight,
ArrowUp10Icon,
CheckCircle2,
ChevronDown,
ChevronsUpDownIcon,
Clock,
MoreHorizontal,
ShieldCheck,
@@ -318,7 +322,26 @@ export default function ProxyResourcesTable({
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => <span className="p-3">{t("name")}</span>
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
@@ -563,6 +586,14 @@ export default function ProxyResourcesTable({
});
}
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());

View File

@@ -141,7 +141,24 @@ export default function SitesTable({
accessorKey: "name",
enableHiding: false,
header: () => {
return <span className="p-3">{t("name")}</span>;
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{

View File

@@ -101,6 +101,7 @@ export function NewtSiteInstallCommands({
`helm install newt fossorial/newt \\
--create-namespace \\
--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.idKey="${id}" \\
--set-string newtInstances[0].auth.keys.secretKey="${secret}"`
@@ -185,59 +186,72 @@ WantedBy=default.target`
className="mt-4"
/>
<div className="pt-4">
<p className="font-bold mb-3">
{t("siteConfiguration")}
</p>
<div className="flex items-center space-x-2 mb-2">
<CheckboxWithLabel
id="acceptClients"
aria-describedby="acceptClients-desc"
checked={acceptClients}
onCheckedChange={(checked) => {
const value = checked as boolean;
setAcceptClients(value);
}}
label={t("siteAcceptClientConnections")}
/>
</div>
<p
id="acceptClients-desc"
className="text-sm text-muted-foreground"
>
{t("siteAcceptClientConnectionsDescription")}
</p>
<div className="pt-4">
<p className="font-bold mb-3">{t("siteConfiguration")}</p>
<div className="flex items-center space-x-2 mb-2">
<CheckboxWithLabel
id="acceptClients"
aria-describedby="acceptClients-desc"
checked={acceptClients}
onCheckedChange={(checked) => {
const value = checked as boolean;
setAcceptClients(value);
}}
label={t("siteAcceptClientConnections")}
/>
</div>
<p
id="acceptClients-desc"
className="text-sm text-muted-foreground"
>
{t("siteAcceptClientConnectionsDescription")}
</p>
</div>
<div className="pt-4">
<p className="font-bold mb-3">{t("commands")}</p>
<div className="mt-2 space-y-3">
{commands.map((item, index) => {
const commandText =
typeof item === "string"
? item
: item.command;
const title =
typeof item === "string"
? undefined
: item.title;
<div className="pt-4">
<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">
{commands.map((item, index) => {
const commandText =
typeof item === "string" ? item : item.command;
const title =
typeof item === "string"
? undefined
: item.title;
return (
<div key={index}>
{title && (
<p className="text-sm font-medium mb-1.5">
{title}
</p>
)}
<CopyTextBox
text={commandText}
outline={true}
/>
</div>
);
})}
</div>
const key = `${title ?? ""}::${commandText}`;
return (
<div key={key}>
{title && (
<p className="text-sm font-medium mb-1.5">
{title}
</p>
)}
<CopyTextBox
text={commandText}
outline={true}
/>
</div>
);
})}
</div>
</div>
</SettingsSectionBody>
</SettingsSection>
);

View File

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