mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-29 23:19:08 +01:00
Merge branch 'dev' into feat/redirect-tables
This commit is contained in:
@@ -47,7 +47,6 @@ export const queryAiSessionLogsQuery = z.strictObject({
|
||||
error: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.optional()
|
||||
.prefault(() => new Date().toISOString())
|
||||
.openapi({
|
||||
type: "string",
|
||||
|
||||
@@ -20,7 +20,6 @@ const queryAccessAuditLogsQuery = z.object({
|
||||
error: "timeStart must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.optional()
|
||||
.prefault(() => getSevenDaysAgo().toISOString())
|
||||
.openapi({
|
||||
type: "string",
|
||||
@@ -34,7 +33,6 @@ const queryAccessAuditLogsQuery = z.object({
|
||||
error: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.optional()
|
||||
.prefault(() => new Date().toISOString())
|
||||
.openapi({
|
||||
type: "string",
|
||||
|
||||
@@ -41,7 +41,6 @@ export const queryAccessAuditLogsQuery = z.strictObject({
|
||||
error: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.optional()
|
||||
.prefault(() => new Date().toISOString())
|
||||
.openapi({
|
||||
type: "string",
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import type {
|
||||
GenerateRegistrationOptionsOpts,
|
||||
GenerateAuthenticationOptionsOpts,
|
||||
AuthenticatorTransportFuture
|
||||
AuthenticatorTransport
|
||||
} from "@simplewebauthn/server";
|
||||
import { isoBase64URL } from "@simplewebauthn/server/helpers";
|
||||
import config from "@server/lib/config";
|
||||
@@ -221,7 +221,7 @@ export async function startRegistration(
|
||||
const excludeCredentials = existingSecurityKeys.map((key) => ({
|
||||
id: key.credentialId,
|
||||
transports: key.transports
|
||||
? (JSON.parse(key.transports) as AuthenticatorTransportFuture[])
|
||||
? (JSON.parse(key.transports) as AuthenticatorTransport[])
|
||||
: undefined
|
||||
}));
|
||||
|
||||
@@ -571,7 +571,7 @@ export async function startAuthentication(
|
||||
transports: key.transports
|
||||
? (JSON.parse(
|
||||
key.transports
|
||||
) as AuthenticatorTransportFuture[])
|
||||
) as AuthenticatorTransport[])
|
||||
: undefined
|
||||
}));
|
||||
}
|
||||
@@ -702,7 +702,7 @@ export async function verifyAuthentication(
|
||||
transports: securityKey.transports
|
||||
? (JSON.parse(
|
||||
securityKey.transports
|
||||
) as AuthenticatorTransportFuture[])
|
||||
) as AuthenticatorTransport[])
|
||||
: undefined
|
||||
},
|
||||
requireUserVerification: false
|
||||
|
||||
@@ -721,7 +721,9 @@ export async function verifyResourceSession(
|
||||
);
|
||||
|
||||
resourceSession = result?.resourceSession;
|
||||
localCache.set(sessionCacheKey, resourceSession, 5);
|
||||
if (resourceSession) {
|
||||
localCache.set(sessionCacheKey, resourceSession, 5);
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceSession?.isRequestToken) {
|
||||
@@ -1216,7 +1218,9 @@ async function allowAccessToken(
|
||||
resource.resourceId
|
||||
);
|
||||
resourceSession = result?.resourceSession;
|
||||
localCache.set(sessionCacheKey, resourceSession, 5);
|
||||
if (resourceSession) {
|
||||
localCache.set(sessionCacheKey, resourceSession, 5);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -21,6 +21,7 @@ import { LimitId } from "@server/lib/billing";
|
||||
import { isSecondLevelDomain, isValidDomain } from "@server/lib/validators";
|
||||
import { build } from "@server/build";
|
||||
import config from "@server/lib/config";
|
||||
import { createNs, createCname } from "#dynamic/lib/dns/generateDomains";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -283,8 +284,7 @@ export async function createOrgDomain(
|
||||
|
||||
// TODO: This needs to be cross region and not hardcoded
|
||||
if (type === "ns") {
|
||||
nsRecords = config.getRawConfig().dns.nameservers as string[];
|
||||
|
||||
nsRecords = createNs();
|
||||
// Save NS records to database
|
||||
for (const nsValue of nsRecords) {
|
||||
recordsToInsert.push({
|
||||
@@ -296,16 +296,7 @@ export async function createOrgDomain(
|
||||
});
|
||||
}
|
||||
} else if (type === "cname") {
|
||||
cnameRecords = [
|
||||
{
|
||||
value: `${domainId}.${config.getRawConfig().dns.cname_extension}`,
|
||||
baseDomain: baseDomain
|
||||
},
|
||||
{
|
||||
value: `_acme-challenge.${domainId}.${config.getRawConfig().dns.cname_extension}`,
|
||||
baseDomain: `_acme-challenge.${baseDomain}`
|
||||
}
|
||||
];
|
||||
cnameRecords = createCname(domainId, baseDomain);
|
||||
|
||||
// Save CNAME records to database
|
||||
for (const cnameRecord of cnameRecords) {
|
||||
|
||||
@@ -88,6 +88,12 @@ authenticated.get("/org/checkId", org.checkId);
|
||||
authenticated.put("/org", getUserOrgs, org.createOrg);
|
||||
|
||||
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
|
||||
authenticated.get("/admin/orgs", verifyUserIsServerAdmin, org.adminListOrgs);
|
||||
authenticated.delete(
|
||||
"/admin/org/:orgId",
|
||||
verifyUserIsServerAdmin,
|
||||
org.adminDeleteOrg
|
||||
);
|
||||
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
|
||||
|
||||
authenticated.get(
|
||||
@@ -1379,6 +1385,12 @@ if (build !== "saas") {
|
||||
user.adminGeneratePasswordResetCode
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/user/:userId/server-admin",
|
||||
verifyUserIsServerAdmin,
|
||||
user.adminSetServerAdmin
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/user/:userId",
|
||||
verifyUserIsServerAdmin,
|
||||
|
||||
@@ -10,6 +10,7 @@ import config from "@server/lib/config";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { getAllowedIps } from "../target/helpers";
|
||||
import { createExitNode } from "#dynamic/routers/gerbil/createExitNode";
|
||||
import { markExitNodeCheckedIn } from "@server/lib/exitNodes";
|
||||
|
||||
// Define Zod schema for request validation
|
||||
const getConfigSchema = z.object({
|
||||
@@ -65,6 +66,8 @@ export async function getConfig(
|
||||
);
|
||||
}
|
||||
|
||||
markExitNodeCheckedIn(exitNode.exitNodeId);
|
||||
|
||||
const configResponse = await generateGerbilConfig(exitNode);
|
||||
|
||||
logger.debug("Sending config: ", configResponse);
|
||||
|
||||
@@ -522,6 +522,7 @@ async function fetchLabelsForResources(
|
||||
type SiteGroupRow = {
|
||||
siteId: number;
|
||||
name: string;
|
||||
niceId: string;
|
||||
type: string;
|
||||
online: boolean;
|
||||
itemCount: number;
|
||||
@@ -556,6 +557,7 @@ async function listSiteGroups(
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
name: sites.name,
|
||||
niceId: sites.niceId,
|
||||
type: sites.type,
|
||||
online: sites.online,
|
||||
itemCount: countDistinct(resources.resourceId)
|
||||
@@ -576,7 +578,13 @@ async function listSiteGroups(
|
||||
|
||||
const publicRows = await publicQuery
|
||||
.where(and(...publicConditions))
|
||||
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
|
||||
.groupBy(
|
||||
sites.siteId,
|
||||
sites.name,
|
||||
sites.niceId,
|
||||
sites.type,
|
||||
sites.online
|
||||
);
|
||||
|
||||
for (const row of publicRows) {
|
||||
const existing = siteCountMap.get(row.siteId);
|
||||
@@ -586,6 +594,7 @@ async function listSiteGroups(
|
||||
siteCountMap.set(row.siteId, {
|
||||
siteId: row.siteId,
|
||||
name: row.name,
|
||||
niceId: row.niceId,
|
||||
type: row.type,
|
||||
online: row.online,
|
||||
itemCount: Number(row.itemCount)
|
||||
@@ -612,6 +621,7 @@ async function listSiteGroups(
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
name: sites.name,
|
||||
niceId: sites.niceId,
|
||||
type: sites.type,
|
||||
online: sites.online,
|
||||
itemCount: countDistinct(siteResources.siteResourceId)
|
||||
@@ -638,7 +648,13 @@ async function listSiteGroups(
|
||||
|
||||
const siteRows = await siteResourceQuery
|
||||
.where(and(...siteConditions))
|
||||
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
|
||||
.groupBy(
|
||||
sites.siteId,
|
||||
sites.name,
|
||||
sites.niceId,
|
||||
sites.type,
|
||||
sites.online
|
||||
);
|
||||
|
||||
for (const row of siteRows) {
|
||||
const existing = siteCountMap.get(row.siteId);
|
||||
@@ -648,6 +664,7 @@ async function listSiteGroups(
|
||||
siteCountMap.set(row.siteId, {
|
||||
siteId: row.siteId,
|
||||
name: row.name,
|
||||
niceId: row.niceId,
|
||||
type: row.type,
|
||||
online: row.online,
|
||||
itemCount: Number(row.itemCount)
|
||||
@@ -1061,6 +1078,43 @@ export async function listLauncherGroupsForUser(
|
||||
};
|
||||
}
|
||||
|
||||
function toLauncherSiteInfo(row: {
|
||||
siteId: number | null;
|
||||
siteName: string | null;
|
||||
siteNiceId: string | null;
|
||||
siteType: string | null;
|
||||
siteOnline: boolean | null;
|
||||
}): LauncherSiteInfo | null {
|
||||
if (
|
||||
row.siteId == null ||
|
||||
row.siteName == null ||
|
||||
row.siteNiceId == null ||
|
||||
row.siteType == null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
siteId: row.siteId,
|
||||
name: row.siteName,
|
||||
niceId: row.siteNiceId,
|
||||
type: row.siteType,
|
||||
online: row.siteOnline ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
function pickPrimarySite(
|
||||
sites: LauncherSiteInfo[],
|
||||
siteIdFilter?: number
|
||||
): LauncherSiteInfo | undefined {
|
||||
if (sites.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (siteIdFilter != null) {
|
||||
return sites.find((site) => site.siteId === siteIdFilter) ?? sites[0];
|
||||
}
|
||||
return sites[0];
|
||||
}
|
||||
|
||||
async function mapPublicResources(
|
||||
orgId: string,
|
||||
resourceIds: number[],
|
||||
@@ -1084,6 +1138,7 @@ async function mapPublicResources(
|
||||
enabled: resources.enabled,
|
||||
siteId: sites.siteId,
|
||||
siteName: sites.name,
|
||||
siteNiceId: sites.niceId,
|
||||
siteType: sites.type,
|
||||
siteOnline: sites.online,
|
||||
exitNodeEndpoint: exitNodes.endpoint
|
||||
@@ -1097,56 +1152,65 @@ async function mapPublicResources(
|
||||
inArray(resources.resourceId, resourceIds),
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.enabled, true),
|
||||
eq(resources.status, "approved"),
|
||||
siteIdFilter != null
|
||||
? eq(sites.siteId, siteIdFilter)
|
||||
: undefined
|
||||
eq(resources.status, "approved")
|
||||
)
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const result: LauncherResource[] = [];
|
||||
const byKey = new Map<string, LauncherResource>();
|
||||
const siteIdsByKey = new Map<string, Set<number>>();
|
||||
|
||||
for (const row of rows) {
|
||||
const key = `public:${row.resourceId}`;
|
||||
if (seen.has(key)) {
|
||||
let item = byKey.get(key);
|
||||
|
||||
if (!item) {
|
||||
const access = formatPublicResourceAccess({
|
||||
mode: row.mode,
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
proxyPort: row.proxyPort,
|
||||
wildcard: row.wildcard,
|
||||
exitNodeEndpoint: row.exitNodeEndpoint
|
||||
});
|
||||
|
||||
item = {
|
||||
launcherResourceKey: key,
|
||||
resourceType: "public",
|
||||
resourceId: row.resourceId,
|
||||
niceId: row.niceId,
|
||||
name: row.name,
|
||||
...access,
|
||||
iconUrl: null,
|
||||
enabled: row.enabled,
|
||||
mode: row.mode,
|
||||
labels: labelMaps.byResourceId.get(row.resourceId) ?? [],
|
||||
sites: []
|
||||
};
|
||||
byKey.set(key, item);
|
||||
siteIdsByKey.set(key, new Set());
|
||||
}
|
||||
|
||||
const site = toLauncherSiteInfo(row);
|
||||
if (!site) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
|
||||
const access = formatPublicResourceAccess({
|
||||
mode: row.mode,
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
proxyPort: row.proxyPort,
|
||||
wildcard: row.wildcard,
|
||||
exitNodeEndpoint: row.exitNodeEndpoint
|
||||
});
|
||||
|
||||
result.push({
|
||||
launcherResourceKey: key,
|
||||
resourceType: "public",
|
||||
resourceId: row.resourceId,
|
||||
niceId: row.niceId,
|
||||
name: row.name,
|
||||
...access,
|
||||
iconUrl: null,
|
||||
enabled: row.enabled,
|
||||
mode: row.mode,
|
||||
labels: labelMaps.byResourceId.get(row.resourceId) ?? [],
|
||||
site:
|
||||
row.siteId != null
|
||||
? {
|
||||
siteId: row.siteId,
|
||||
name: row.siteName!,
|
||||
type: row.siteType!,
|
||||
online: row.siteOnline ?? undefined
|
||||
}
|
||||
: undefined
|
||||
});
|
||||
const seenSiteIds = siteIdsByKey.get(key)!;
|
||||
if (seenSiteIds.has(site.siteId)) {
|
||||
continue;
|
||||
}
|
||||
seenSiteIds.add(site.siteId);
|
||||
item.sites.push(site);
|
||||
}
|
||||
|
||||
return result;
|
||||
for (const item of byKey.values()) {
|
||||
item.sites.sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
||||
);
|
||||
item.site = pickPrimarySite(item.sites, siteIdFilter);
|
||||
}
|
||||
|
||||
return Array.from(byKey.values());
|
||||
}
|
||||
|
||||
async function mapSiteResources(
|
||||
@@ -1175,6 +1239,7 @@ async function mapSiteResources(
|
||||
enabled: siteResources.enabled,
|
||||
siteId: sites.siteId,
|
||||
siteName: sites.name,
|
||||
siteNiceId: sites.niceId,
|
||||
siteType: sites.type,
|
||||
siteOnline: sites.online
|
||||
})
|
||||
@@ -1189,59 +1254,69 @@ async function mapSiteResources(
|
||||
inArray(siteResources.siteResourceId, siteResourceIds),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true),
|
||||
eq(siteResources.status, "approved"),
|
||||
siteIdFilter != null
|
||||
? eq(sites.siteId, siteIdFilter)
|
||||
: undefined
|
||||
eq(siteResources.status, "approved")
|
||||
)
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const result: LauncherResource[] = [];
|
||||
const byKey = new Map<string, LauncherResource>();
|
||||
const siteIdsByKey = new Map<string, Set<number>>();
|
||||
|
||||
for (const row of rows) {
|
||||
const key = `site:${row.siteResourceId}`;
|
||||
if (seen.has(key)) {
|
||||
let item = byKey.get(key);
|
||||
|
||||
if (!item) {
|
||||
const access = formatSiteResourceAccess({
|
||||
mode: row.mode,
|
||||
destination: row.destination,
|
||||
destinationPort: row.destinationPort,
|
||||
scheme: row.scheme,
|
||||
ssl: row.ssl,
|
||||
fullDomain: row.fullDomain,
|
||||
alias: row.alias,
|
||||
aliasAddress: row.aliasAddress
|
||||
});
|
||||
|
||||
item = {
|
||||
launcherResourceKey: key,
|
||||
resourceType: "site",
|
||||
resourceId: row.siteResourceId,
|
||||
siteResourceId: row.siteResourceId,
|
||||
niceId: row.niceId,
|
||||
name: row.name,
|
||||
...access,
|
||||
iconUrl: null,
|
||||
enabled: row.enabled,
|
||||
mode: row.mode,
|
||||
labels:
|
||||
labelMaps.bySiteResourceId.get(row.siteResourceId) ?? [],
|
||||
sites: []
|
||||
};
|
||||
byKey.set(key, item);
|
||||
siteIdsByKey.set(key, new Set());
|
||||
}
|
||||
|
||||
const site = toLauncherSiteInfo(row);
|
||||
if (!site) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
|
||||
const access = formatSiteResourceAccess({
|
||||
mode: row.mode,
|
||||
destination: row.destination,
|
||||
destinationPort: row.destinationPort,
|
||||
scheme: row.scheme,
|
||||
ssl: row.ssl,
|
||||
fullDomain: row.fullDomain,
|
||||
alias: row.alias,
|
||||
aliasAddress: row.aliasAddress
|
||||
});
|
||||
|
||||
result.push({
|
||||
launcherResourceKey: key,
|
||||
resourceType: "site",
|
||||
resourceId: row.siteResourceId,
|
||||
siteResourceId: row.siteResourceId,
|
||||
niceId: row.niceId,
|
||||
name: row.name,
|
||||
...access,
|
||||
iconUrl: null,
|
||||
enabled: row.enabled,
|
||||
mode: row.mode,
|
||||
labels: labelMaps.bySiteResourceId.get(row.siteResourceId) ?? [],
|
||||
site:
|
||||
row.siteId != null
|
||||
? {
|
||||
siteId: row.siteId,
|
||||
name: row.siteName!,
|
||||
type: row.siteType!,
|
||||
online: row.siteOnline ?? undefined
|
||||
}
|
||||
: undefined
|
||||
});
|
||||
const seenSiteIds = siteIdsByKey.get(key)!;
|
||||
if (seenSiteIds.has(site.siteId)) {
|
||||
continue;
|
||||
}
|
||||
seenSiteIds.add(site.siteId);
|
||||
item.sites.push(site);
|
||||
}
|
||||
|
||||
return result;
|
||||
for (const item of byKey.values()) {
|
||||
item.sites.sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
||||
);
|
||||
item.site = pickPrimarySite(item.sites, siteIdFilter);
|
||||
}
|
||||
|
||||
return Array.from(byKey.values());
|
||||
}
|
||||
|
||||
function filterResourcesBySite(
|
||||
@@ -1252,13 +1327,17 @@ function filterResourcesBySite(
|
||||
return items.filter((item) => item.mode === "inference");
|
||||
}
|
||||
if (groupKey === LAUNCHER_NO_SITE_GROUP_KEY) {
|
||||
return items.filter((item) => !item.site && item.mode !== "inference");
|
||||
return items.filter(
|
||||
(item) => item.sites.length === 0 && item.mode !== "inference"
|
||||
);
|
||||
}
|
||||
const siteId = Number.parseInt(groupKey, 10);
|
||||
if (!Number.isFinite(siteId)) {
|
||||
return items;
|
||||
}
|
||||
return items.filter((item) => item.site?.siteId === siteId);
|
||||
return items.filter((item) =>
|
||||
item.sites.some((site) => site.siteId === siteId)
|
||||
);
|
||||
}
|
||||
|
||||
function filterResourcesByLabel(
|
||||
@@ -1499,6 +1578,7 @@ async function collectAccessibleSites(
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
name: sites.name,
|
||||
niceId: sites.niceId,
|
||||
type: sites.type,
|
||||
online: sites.online,
|
||||
itemCount: countDistinct(resources.resourceId)
|
||||
@@ -1507,7 +1587,13 @@ async function collectAccessibleSites(
|
||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
||||
.where(and(...publicConditions))
|
||||
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
|
||||
.groupBy(
|
||||
sites.siteId,
|
||||
sites.name,
|
||||
sites.niceId,
|
||||
sites.type,
|
||||
sites.online
|
||||
);
|
||||
|
||||
for (const row of publicRows) {
|
||||
const existing = siteCountMap.get(row.siteId);
|
||||
@@ -1517,6 +1603,7 @@ async function collectAccessibleSites(
|
||||
siteCountMap.set(row.siteId, {
|
||||
siteId: row.siteId,
|
||||
name: row.name,
|
||||
niceId: row.niceId,
|
||||
type: row.type,
|
||||
online: row.online,
|
||||
itemCount: Number(row.itemCount)
|
||||
@@ -1540,6 +1627,7 @@ async function collectAccessibleSites(
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
name: sites.name,
|
||||
niceId: sites.niceId,
|
||||
type: sites.type,
|
||||
online: sites.online,
|
||||
itemCount: countDistinct(siteResources.siteResourceId)
|
||||
@@ -1551,7 +1639,13 @@ async function collectAccessibleSites(
|
||||
)
|
||||
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||
.where(and(...siteConditions))
|
||||
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
|
||||
.groupBy(
|
||||
sites.siteId,
|
||||
sites.name,
|
||||
sites.niceId,
|
||||
sites.type,
|
||||
sites.online
|
||||
);
|
||||
|
||||
for (const row of siteRows) {
|
||||
const existing = siteCountMap.get(row.siteId);
|
||||
@@ -1561,6 +1655,7 @@ async function collectAccessibleSites(
|
||||
siteCountMap.set(row.siteId, {
|
||||
siteId: row.siteId,
|
||||
name: row.name,
|
||||
niceId: row.niceId,
|
||||
type: row.type,
|
||||
online: row.online,
|
||||
itemCount: Number(row.itemCount)
|
||||
@@ -1675,6 +1770,7 @@ export async function listAccessibleLauncherSitesForUser(
|
||||
.map((row) => ({
|
||||
siteId: row.siteId,
|
||||
name: row.name,
|
||||
niceId: row.niceId,
|
||||
type: row.type,
|
||||
online: row.online
|
||||
}))
|
||||
|
||||
@@ -32,6 +32,7 @@ export type LauncherLabel = {
|
||||
export type LauncherSiteInfo = {
|
||||
siteId: number;
|
||||
name: string;
|
||||
niceId: string;
|
||||
type: string;
|
||||
online?: boolean;
|
||||
};
|
||||
@@ -51,6 +52,7 @@ export type LauncherResource = {
|
||||
mode: string;
|
||||
labels: LauncherLabel[];
|
||||
site?: LauncherSiteInfo;
|
||||
sites: LauncherSiteInfo[];
|
||||
};
|
||||
|
||||
export type LauncherGroup = {
|
||||
@@ -184,8 +186,7 @@ export function parseIdListParam(value: string | undefined): number[] {
|
||||
export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
|
||||
|
||||
export type LauncherViewSelection =
|
||||
| { type: "default" }
|
||||
| { type: "saved"; viewId: number };
|
||||
{ type: "default" } | { type: "saved"; viewId: number };
|
||||
|
||||
export type LauncherScaleCapabilities = {
|
||||
allowSiteGrouping: boolean;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
|
||||
// Error codes for registration failures
|
||||
export const NewtErrorCodes = {
|
||||
NO_AVAILABLE_SUBNET: {
|
||||
code: "NO_AVAILABLE_SUBNET",
|
||||
message:
|
||||
"No available subnet could be assigned to this site on its exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
|
||||
}
|
||||
} as const;
|
||||
|
||||
// Helper function to send registration error
|
||||
export async function sendNewtError(
|
||||
error: (typeof NewtErrorCodes)[keyof typeof NewtErrorCodes],
|
||||
newtId: string
|
||||
) {
|
||||
sendToClient(newtId, {
|
||||
type: "newt/error",
|
||||
data: {
|
||||
code: error.code,
|
||||
message: error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -13,31 +13,40 @@ import logger from "@server/logger";
|
||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
// Stale-while-revalidate in-memory fallback for the releases API.
|
||||
type ReleaseInfo = {
|
||||
version: string;
|
||||
// binary filename -> sha256 hex (sourced from asset `digest` field in GitHub API)
|
||||
assetDigests: Record<string, string>;
|
||||
};
|
||||
let staleReleaseInfo: ReleaseInfo | null = null;
|
||||
|
||||
// Cache key holding the last known good release info. It never expires, so
|
||||
// it keeps serving if GitHub is unreachable, even across restarts/nodes.
|
||||
const RELEASE_INFO_KEY = "cache:releaseInfo";
|
||||
// Short-lived marker controlling how often we re-check GitHub. While it's
|
||||
// missing (expired, or a previous attempt failed) every request retries.
|
||||
const RELEASE_INFO_FRESH_KEY = "cache:releaseInfoFresh";
|
||||
const RELEASE_INFO_REFRESH_SECONDS = 3600;
|
||||
|
||||
/**
|
||||
* Fetches the latest stable newt release from GitHub and returns the version
|
||||
* tag together with a map of asset-name → sha256 hex digest.
|
||||
* Results are cached for one hour; stale data is returned on failure.
|
||||
* The last successful result is cached indefinitely and re-checked hourly;
|
||||
* on failure the last known good data keeps being served and every
|
||||
* subsequent request retries GitHub until it succeeds again.
|
||||
*/
|
||||
async function getLatestReleaseInfo(): Promise<ReleaseInfo | null> {
|
||||
try {
|
||||
const cached = await cache.get<ReleaseInfo>("cache:newtReleaseInfo");
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
async function getLatestReleaseInfo(repo: string): Promise<ReleaseInfo | null> {
|
||||
const stored = await cache.get<ReleaseInfo>(`${RELEASE_INFO_KEY}:${repo}`);
|
||||
const isFresh = await cache.has(`${RELEASE_INFO_FRESH_KEY}:${repo}`);
|
||||
if (stored && isFresh) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const fetchResponse = await fetch(
|
||||
"https://api.github.com/repos/fosrl/newt/releases",
|
||||
`https://api.github.com/repos/fosrl/${repo}/releases`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
@@ -47,13 +56,13 @@ async function getLatestReleaseInfo(): Promise<ReleaseInfo | null> {
|
||||
logger.warn(
|
||||
`Failed to fetch Newt releases from GitHub: ${fetchResponse.status} ${fetchResponse.statusText}`
|
||||
);
|
||||
return staleReleaseInfo;
|
||||
return stored ?? null;
|
||||
}
|
||||
|
||||
let releases: any[] = await fetchResponse.json();
|
||||
if (!Array.isArray(releases) || releases.length === 0) {
|
||||
logger.warn("No releases found for Newt repository");
|
||||
return staleReleaseInfo;
|
||||
logger.warn("No releases found for repository");
|
||||
return stored ?? null;
|
||||
}
|
||||
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
@@ -81,8 +90,8 @@ async function getLatestReleaseInfo(): Promise<ReleaseInfo | null> {
|
||||
});
|
||||
|
||||
if (releases.length === 0) {
|
||||
logger.warn("No stable releases found for Newt repository");
|
||||
return staleReleaseInfo;
|
||||
logger.warn("No stable releases found for repository");
|
||||
return stored ?? null;
|
||||
}
|
||||
|
||||
const latest = releases[0];
|
||||
@@ -106,8 +115,12 @@ async function getLatestReleaseInfo(): Promise<ReleaseInfo | null> {
|
||||
}
|
||||
|
||||
const info: ReleaseInfo = { version, assetDigests };
|
||||
staleReleaseInfo = info;
|
||||
await cache.set("cache:newtReleaseInfo", info, 3600);
|
||||
await cache.set(RELEASE_INFO_KEY, info, 0);
|
||||
await cache.set(
|
||||
RELEASE_INFO_FRESH_KEY,
|
||||
true,
|
||||
RELEASE_INFO_REFRESH_SECONDS
|
||||
);
|
||||
return info;
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError") {
|
||||
@@ -118,14 +131,15 @@ async function getLatestReleaseInfo(): Promise<ReleaseInfo | null> {
|
||||
error.message || error
|
||||
);
|
||||
}
|
||||
return staleReleaseInfo;
|
||||
return stored ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const bodySchema = z.object({
|
||||
newtId: z.string(),
|
||||
secret: z.string(),
|
||||
platform: z.string() // e.g. "linux_amd64", "darwin_arm64"
|
||||
platform: z.string(), // e.g. "linux_amd64", "darwin_arm64"
|
||||
agent: z.string().optional().default("newt")
|
||||
});
|
||||
|
||||
export type GetNewtVersionBody = z.infer<typeof bodySchema>;
|
||||
@@ -153,7 +167,7 @@ export async function getNewtVersion(
|
||||
);
|
||||
}
|
||||
|
||||
const { newtId, secret, platform } = parsedBody.data;
|
||||
const { newtId, secret, platform, agent } = parsedBody.data;
|
||||
|
||||
try {
|
||||
// Verify newt credentials
|
||||
@@ -258,9 +272,13 @@ export async function getNewtVersion(
|
||||
}
|
||||
|
||||
// Fetch latest release info (version + asset digests) in one API call.
|
||||
const releaseInfo = await getLatestReleaseInfo();
|
||||
const releaseInfoNewt = await getLatestReleaseInfo("newt");
|
||||
let releaseInfoCli: ReleaseInfo | undefined | null;
|
||||
if (agent == "cli") {
|
||||
releaseInfoCli = await getLatestReleaseInfo("cli");
|
||||
}
|
||||
|
||||
if (!releaseInfo) {
|
||||
if (!releaseInfoNewt || (agent == "cli" && !releaseInfoCli)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
@@ -269,18 +287,25 @@ export async function getNewtVersion(
|
||||
);
|
||||
}
|
||||
|
||||
const latestVersion = releaseInfo.version;
|
||||
const latestVersion = releaseInfoNewt.version;
|
||||
|
||||
// Binary name follows the get-newt.sh convention: newt_<platform>[.exe]
|
||||
const binaryName = platform.includes("windows")
|
||||
const binaryNameNewt = platform.includes("windows")
|
||||
? `newt_${platform}.exe`
|
||||
: `newt_${platform}`;
|
||||
|
||||
const downloadUrl = `https://github.com/fosrl/newt/releases/download/${latestVersion}/${binaryName}`;
|
||||
const binaryNameCli = platform.includes("windows")
|
||||
? `pangolin-cli_${platform}.exe`
|
||||
: `pangolin-cli_${platform}`;
|
||||
|
||||
const downloadUrl = `https://github.com/fosrl/${agent}/releases/download/${agent == "cli" ? releaseInfoCli?.version : releaseInfoNewt.version}/${agent == "cli" ? binaryNameCli : binaryNameNewt}`;
|
||||
|
||||
// Look up the SHA256 digest for this specific binary from the GitHub
|
||||
// release asset metadata (the `digest` field, format "sha256:<hex>").
|
||||
const sha256 = releaseInfo.assetDigests[binaryName] ?? "";
|
||||
const sha256 =
|
||||
releaseInfoNewt.assetDigests[
|
||||
agent == "cli" ? binaryNameCli : binaryNameNewt
|
||||
] ?? "";
|
||||
|
||||
// Determine whether the newt that's asking is already up to date.
|
||||
// We store the current version on the newt row when it registers.
|
||||
@@ -300,8 +325,8 @@ export async function getNewtVersion(
|
||||
|
||||
return response<GetNewtVersionResponse>(res, {
|
||||
data: {
|
||||
latestVersion,
|
||||
currentIsLatest,
|
||||
latestVersion, // this will always be the newt version
|
||||
currentIsLatest, // this will always be based on the newt version
|
||||
downloadUrl,
|
||||
sha256
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
|
||||
import { fetchContainers } from "./dockerSocket";
|
||||
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import { NewtErrorCodes, sendNewtError } from "./error";
|
||||
|
||||
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
@@ -37,6 +38,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
publicKey,
|
||||
pingResults,
|
||||
newtVersion,
|
||||
agent,
|
||||
agentVersion,
|
||||
backwardsCompatible,
|
||||
chainId
|
||||
} = message.data;
|
||||
@@ -114,6 +117,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
logger.error(
|
||||
`No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}`
|
||||
);
|
||||
sendNewtError(NewtErrorCodes.NO_AVAILABLE_SUBNET, newt.newtId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,22 +173,21 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
logger.error(`Failed to add peer to exit node: ${error}`);
|
||||
}
|
||||
|
||||
if (newtVersion && newtVersion !== newt.version) {
|
||||
if (
|
||||
newtVersion !== newt.version ||
|
||||
agent !== newt.agent ||
|
||||
agentVersion !== newt.agentVersion
|
||||
) {
|
||||
// update the newt version in the database
|
||||
await db
|
||||
.update(newts)
|
||||
.set({
|
||||
version: newtVersion as string
|
||||
})
|
||||
.where(eq(newts.newtId, newt.newtId));
|
||||
}
|
||||
|
||||
if (newtVersion && newtVersion !== newt.version) {
|
||||
// update the newt version in the database
|
||||
await db
|
||||
.update(newts)
|
||||
.set({
|
||||
version: newtVersion as string
|
||||
version: newtVersion as string,
|
||||
agent: agent,
|
||||
agentVersion:
|
||||
!agentVersion && agent == "newt"
|
||||
? newtVersion
|
||||
: agentVersion
|
||||
})
|
||||
.where(eq(newts.newtId, newt.newtId));
|
||||
}
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { sites, Newt, orgs, clients, clientSitesAssociationsCache, users } from "@server/db";
|
||||
|
||||
@@ -94,6 +94,11 @@ export const OlmErrorCodes = {
|
||||
HOLEPUNCH_MISSING: {
|
||||
code: "HOLEPUNCH_MISSING",
|
||||
message: `Unable to coordinate client P2P connection. Please ensure your client can reach the server on UDP port ${udpPort} and try registering again.`
|
||||
},
|
||||
NO_AVAILABLE_SUBNET: {
|
||||
code: "NO_AVAILABLE_SUBNET",
|
||||
message:
|
||||
"No available subnet could be assigned to this client on the selected exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
|
||||
}
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -347,6 +347,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
|
||||
{ orgId: client.orgId, clientId: client.clientId }
|
||||
);
|
||||
sendOlmError(OlmErrorCodes.NO_AVAILABLE_SUBNET, olm.olmId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
|
||||
import { db, orgs } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const adminDeleteOrgSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
export type AdminDeleteOrgResponse = {};
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/admin/org/{orgId}",
|
||||
description: "Delete any organization in the system (server admin).",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: adminDeleteOrgSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function adminDeleteOrg(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = adminDeleteOrgSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Organization with ID ${orgId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const result = await deleteOrgById(orgId);
|
||||
sendTerminationMessages(result);
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Organization deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred..."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, users } from "@server/db";
|
||||
import { orgs, resources, sites, userOrgs } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
|
||||
const adminListOrgsSchema = z.strictObject({
|
||||
pageSize: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
sort_by: z
|
||||
.enum(["name", "createdAt"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["name", "createdAt"],
|
||||
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 AdminOrgRow = {
|
||||
orgId: string;
|
||||
name: string;
|
||||
subnet: string | null;
|
||||
utilitySubnet: string | null;
|
||||
createdAt: string | null;
|
||||
userCount: number;
|
||||
siteCount: number;
|
||||
resourceCount: number;
|
||||
owner: {
|
||||
userId: string;
|
||||
username: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type AdminListOrgsResponse = PaginatedResponse<{
|
||||
orgs: AdminOrgRow[];
|
||||
}>;
|
||||
|
||||
const AdminListOrgsResponseDataSchema = z.object({
|
||||
orgs: z.array(
|
||||
z.object({
|
||||
orgId: z.string(),
|
||||
name: z.string(),
|
||||
subnet: z.string().nullable(),
|
||||
createdAt: z.string().nullable(),
|
||||
userCount: z.number(),
|
||||
siteCount: z.number(),
|
||||
resourceCount: z.number()
|
||||
})
|
||||
),
|
||||
pagination: z.object({
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
pageSize: z.number()
|
||||
})
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/admin/orgs",
|
||||
description:
|
||||
"List all organizations in the system with usage counts (server admin).",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
query: adminListOrgsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createApiResponseSchema(
|
||||
AdminListOrgsResponseDataSchema
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function adminListOrgs(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = adminListOrgsSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { pageSize, page, query, sort_by, order } = parsedQuery.data;
|
||||
|
||||
let conditions: (SQL<unknown> | undefined)[] = [];
|
||||
if (query) {
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
conditions.push(
|
||||
or(
|
||||
like(sql`LOWER(${orgs.name})`, q),
|
||||
like(sql`LOWER(${orgs.orgId})`, q),
|
||||
like(sql`LOWER(${orgs.subnet})`, q)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const sortColumns = {
|
||||
name: orgs.name,
|
||||
createdAt: orgs.createdAt
|
||||
} as const;
|
||||
|
||||
const orderBy = sort_by
|
||||
? order === "asc"
|
||||
? asc(sortColumns[sort_by])
|
||||
: desc(sortColumns[sort_by])
|
||||
: asc(orgs.name);
|
||||
|
||||
// Drizzle renders bare column references in the select list without their
|
||||
// table prefix, which would make a correlated subquery compare a column to
|
||||
// itself, so the outer `orgs` side is qualified explicitly.
|
||||
const orgIdRef = sql`${sql.identifier("orgs")}.${sql.identifier("orgId")}`;
|
||||
|
||||
const [countRows, rows] = await Promise.all([
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(orgs)
|
||||
.where(and(...conditions)),
|
||||
db
|
||||
.selectDistinct({
|
||||
orgId: orgs.orgId,
|
||||
name: orgs.name,
|
||||
subnet: orgs.subnet,
|
||||
utilitySubnet: orgs.utilitySubnet,
|
||||
createdAt: orgs.createdAt,
|
||||
userCount: sql<number>`(
|
||||
SELECT COUNT(*)
|
||||
FROM ${userOrgs}
|
||||
WHERE ${userOrgs.orgId} = ${orgIdRef}
|
||||
)`.as("userCount"),
|
||||
siteCount: sql<number>`(
|
||||
SELECT COUNT(*)
|
||||
FROM ${sites}
|
||||
WHERE ${sites.orgId} = ${orgIdRef}
|
||||
)`.as("siteCount"),
|
||||
resourceCount: sql<number>`(
|
||||
SELECT COUNT(*)
|
||||
FROM ${resources}
|
||||
WHERE ${resources.orgId} = ${orgIdRef}
|
||||
)`.as("resourceCount"),
|
||||
owner: {
|
||||
userId: users.userId,
|
||||
username: users.username
|
||||
}
|
||||
})
|
||||
.from(orgs)
|
||||
.where(and(...conditions, eq(userOrgs.isOwner, true)))
|
||||
.leftJoin(userOrgs, eq(userOrgs.orgId, orgs.orgId))
|
||||
.leftJoin(users, eq(userOrgs.userId, users.userId))
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(orderBy)
|
||||
]);
|
||||
|
||||
const totalCount = Number(countRows[0]?.count ?? 0);
|
||||
|
||||
return response<AdminListOrgsResponse>(res, {
|
||||
data: {
|
||||
orgs: rows.map((row) => ({
|
||||
...row,
|
||||
userCount: Number(row.userCount ?? 0),
|
||||
siteCount: Number(row.siteCount ?? 0),
|
||||
resourceCount: Number(row.resourceCount ?? 0)
|
||||
})),
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Organizations retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred..."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,5 @@ export * from "./listOrgs";
|
||||
export * from "./pickOrgDefaults";
|
||||
export * from "./checkOrgUserAccess";
|
||||
export * from "./resetOrgBandwidth";
|
||||
export * from "./adminListOrgs";
|
||||
export * from "./adminDeleteOrg";
|
||||
|
||||
@@ -48,6 +48,8 @@ type SiteQueryRow = NonNullable<Awaited<ReturnType<typeof query>>>;
|
||||
export type GetSiteResponse = SiteQueryRow["sites"] & {
|
||||
newtId: string | null;
|
||||
newtVersion: string | null;
|
||||
agent: string | null;
|
||||
agentVersion: string | null;
|
||||
countryCode: string | null;
|
||||
};
|
||||
|
||||
@@ -137,6 +139,8 @@ export async function getSite(
|
||||
...site.sites,
|
||||
newtId: site.newt ? site.newt.newtId : null,
|
||||
newtVersion: site.newt?.version ?? null,
|
||||
agent: site.newt?.agent ?? null,
|
||||
agentVersion: site.newt?.agentVersion ?? null,
|
||||
countryCode: site.sites.endpoint
|
||||
? ((await getCountryCodeForIp(site.sites.endpoint)) ?? null)
|
||||
: null
|
||||
|
||||
@@ -133,6 +133,8 @@ function querySitesBase() {
|
||||
online: sites.online,
|
||||
address: sites.address,
|
||||
newtVersion: newts.version,
|
||||
agent: newts.agent,
|
||||
agentVersion: newts.agentVersion,
|
||||
exitNodeId: sites.exitNodeId,
|
||||
exitNodeName: exitNodes.name,
|
||||
exitNodeEndpoint: exitNodes.endpoint,
|
||||
|
||||
@@ -43,7 +43,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
||||
const { message, client: c } = context;
|
||||
const newt = c as Newt;
|
||||
|
||||
logger.info("Handling healthcheck status message");
|
||||
logger.debug("Handling healthcheck status message");
|
||||
|
||||
if (!newt) {
|
||||
logger.warn("Newt not found");
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db, idp, users } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
@@ -88,6 +88,15 @@ const listUsersSchema = z.strictObject({
|
||||
type: "boolean",
|
||||
description:
|
||||
"Filter by 2FA state matching: enabled if twoFactorEnabled or twoFactorSetupRequested"
|
||||
}),
|
||||
server_admin: z
|
||||
.enum(["true", "false"])
|
||||
.transform((v) => v === "true")
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "boolean",
|
||||
description: "Filter by server admin status"
|
||||
})
|
||||
});
|
||||
|
||||
@@ -177,7 +186,8 @@ export async function adminListUsers(
|
||||
sort_by,
|
||||
order,
|
||||
idp_id,
|
||||
two_factor: twoFactorFilter
|
||||
two_factor: twoFactorFilter,
|
||||
server_admin: serverAdminFilter
|
||||
} = parsedQuery.data;
|
||||
|
||||
if (typeof idp_id === "number") {
|
||||
@@ -196,7 +206,7 @@ export async function adminListUsers(
|
||||
}
|
||||
}
|
||||
|
||||
const conditions = [eq(users.serverAdmin, false)];
|
||||
const conditions: Array<SQL<unknown> | undefined> = [];
|
||||
|
||||
if (query) {
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
@@ -233,6 +243,10 @@ export async function adminListUsers(
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof serverAdminFilter === "boolean") {
|
||||
conditions.push(eq(users.serverAdmin, serverAdminFilter));
|
||||
}
|
||||
|
||||
const whereClause = and(...conditions);
|
||||
|
||||
const countQuery = db.$count(
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
|
||||
|
||||
const setServerAdminParamsSchema = z.strictObject({
|
||||
userId: z.string()
|
||||
});
|
||||
|
||||
const setServerAdminBodySchema = z.strictObject({
|
||||
serverAdmin: z.boolean()
|
||||
});
|
||||
|
||||
export type AdminSetServerAdminResponse = {
|
||||
userId: string;
|
||||
serverAdmin: boolean;
|
||||
};
|
||||
|
||||
const AdminSetServerAdminResponseDataSchema = z.object({
|
||||
userId: z.string(),
|
||||
serverAdmin: z.boolean()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/user/{userId}/server-admin",
|
||||
description:
|
||||
"Promote or demote a user's server admin status (server admin).",
|
||||
tags: [OpenAPITags.User],
|
||||
request: {
|
||||
params: setServerAdminParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setServerAdminBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createApiResponseSchema(
|
||||
AdminSetServerAdminResponseDataSchema
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function adminSetServerAdmin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = setServerAdminParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = setServerAdminBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { userId } = parsedParams.data;
|
||||
const { serverAdmin } = parsedBody.data;
|
||||
|
||||
const [existingUser] = await db
|
||||
.select({
|
||||
userId: users.userId,
|
||||
serverAdmin: users.serverAdmin,
|
||||
type: users.type
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!existingUser) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
if (existingUser.type !== "internal") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Server admin status can only be changed for internal users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!serverAdmin && req.user?.userId === userId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"You cannot remove your own server admin status"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (existingUser.serverAdmin !== serverAdmin) {
|
||||
logger.info(
|
||||
`${serverAdmin ? "Promoting" : "Demoting"} user ${userId} ${serverAdmin ? "to" : "from"} server admin (by ${req.user?.userId})`
|
||||
);
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ serverAdmin })
|
||||
.where(eq(users.userId, userId));
|
||||
}
|
||||
|
||||
return response<AdminSetServerAdminResponse>(res, {
|
||||
data: {
|
||||
userId: existingUser.userId,
|
||||
serverAdmin
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: serverAdmin
|
||||
? "User promoted to server admin successfully"
|
||||
: "User demoted from server admin successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export * from "./adminListUsers";
|
||||
export * from "./adminRemoveUser";
|
||||
export * from "./adminGetUser";
|
||||
export * from "./adminGeneratePasswordResetCode";
|
||||
export * from "./adminSetServerAdmin";
|
||||
export * from "./listInvitations";
|
||||
export * from "./removeInvitation";
|
||||
export * from "./createOrgUser";
|
||||
|
||||
@@ -95,22 +95,34 @@ const listUsersSchema = z.strictObject({
|
||||
'Filter by identity provider id, or "internal" for internal users'
|
||||
}),
|
||||
role_id: z
|
||||
.preprocess((val) => {
|
||||
if (val === undefined || val === null || val === "") {
|
||||
return undefined;
|
||||
}
|
||||
const raw = Array.isArray(val) ? val : [val];
|
||||
const nums = raw
|
||||
.map((v) =>
|
||||
typeof v === "string" ? parseInt(v, 10) : Number(v)
|
||||
)
|
||||
.filter((n) => Number.isInteger(n) && n > 0);
|
||||
const unique = [...new Set(nums)];
|
||||
return unique.length ? unique : undefined;
|
||||
}, z.array(z.number().int().positive()).optional())
|
||||
.preprocess(
|
||||
(val) => {
|
||||
if (val === undefined || val === null || val === "") {
|
||||
return undefined;
|
||||
}
|
||||
const raw = Array.isArray(val) ? val : [val];
|
||||
const includeOwner = raw.some((v) => v === "owner");
|
||||
const nums = raw
|
||||
.map((v) =>
|
||||
typeof v === "string" ? parseInt(v, 10) : Number(v)
|
||||
)
|
||||
.filter((n) => Number.isInteger(n) && n > 0);
|
||||
const unique = [...new Set(nums)];
|
||||
if (!unique.length && !includeOwner) {
|
||||
return undefined;
|
||||
}
|
||||
return { roleIds: unique, includeOwner };
|
||||
},
|
||||
z
|
||||
.object({
|
||||
roleIds: z.array(z.number().int().positive()),
|
||||
includeOwner: z.boolean()
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.openapi({
|
||||
description:
|
||||
"Filter users who have any of these role ids in the organization (repeat query param)"
|
||||
'Filter users who have any of these role ids in the organization, or "owner" for organization owners (repeat query param)'
|
||||
})
|
||||
});
|
||||
|
||||
@@ -193,7 +205,8 @@ export async function listUsers(
|
||||
}
|
||||
const { page, pageSize, sort_by, order, query, idp_id, role_id } =
|
||||
parsedQuery.data;
|
||||
const roleIds = role_id ?? [];
|
||||
const roleIds = role_id?.roleIds ?? [];
|
||||
const includeOwner = role_id?.includeOwner ?? false;
|
||||
|
||||
const parsedParams = listUsersParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
@@ -267,21 +280,35 @@ export async function listUsers(
|
||||
conditions.push(eq(users.idpId, idp_id));
|
||||
}
|
||||
|
||||
if (roleIds.length > 0) {
|
||||
conditions.push(
|
||||
exists(
|
||||
db
|
||||
.select()
|
||||
.from(userOrgRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, users.userId),
|
||||
eq(userOrgRoles.orgId, orgId),
|
||||
inArray(userOrgRoles.roleId, roleIds)
|
||||
if (roleIds.length > 0 || includeOwner) {
|
||||
const roleFilterParts = [];
|
||||
|
||||
if (includeOwner) {
|
||||
roleFilterParts.push(eq(userOrgs.isOwner, true));
|
||||
}
|
||||
|
||||
if (roleIds.length > 0) {
|
||||
roleFilterParts.push(
|
||||
exists(
|
||||
db
|
||||
.select()
|
||||
.from(userOrgRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, users.userId),
|
||||
eq(userOrgRoles.orgId, orgId),
|
||||
inArray(userOrgRoles.roleId, roleIds)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (roleFilterParts.length === 1) {
|
||||
conditions.push(roleFilterParts[0]);
|
||||
} else if (roleFilterParts.length > 1) {
|
||||
conditions.push(or(...roleFilterParts));
|
||||
}
|
||||
}
|
||||
|
||||
const countQuery = db.$count(
|
||||
|
||||
Reference in New Issue
Block a user