import { clients, currentFingerprint, db, olms, orgs, roleClients, sites, userClients, users } from "@server/db"; import { getUserDeviceName } from "@server/db/names"; import response from "@server/lib/response"; 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, desc, eq, ilike, inArray, isNotNull, isNull, not, or, sql, type SQL } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import NodeCache from "node-cache"; import semver from "semver"; import { z } from "zod"; import { fromError } from "zod-validation-error"; const olmVersionCache = new NodeCache({ stdTTL: 3600 }); async function getLatestOlmVersion(): Promise { try { const cachedVersion = olmVersionCache.get("latestOlmVersion"); if (cachedVersion) { return cachedVersion; } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 1500); const response = await fetch( "https://api.github.com/repos/fosrl/olm/tags", { signal: controller.signal } ); clearTimeout(timeoutId); if (!response.ok) { logger.warn( `Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}` ); return null; } let tags = await response.json(); if (!Array.isArray(tags) || tags.length === 0) { logger.warn("No tags found for Olm repository"); return null; } tags = tags.filter((version) => !version.name.includes("rc")); const latestVersion = tags[0].name; olmVersionCache.set("latestOlmVersion", latestVersion); return latestVersion; } catch (error: any) { if (error.name === "AbortError") { logger.warn("Request to fetch latest Olm version timed out (1.5s)"); } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { logger.warn("Connection timeout while fetching latest Olm version"); } else { logger.warn( "Error fetching latest Olm version:", error.message || error ); } return null; } } const listUserDevicesParamsSchema = z.strictObject({ orgId: z.string() }); const listUserDevicesSchema = z.object({ pageSize: z.coerce .number() // for prettier formatting .int() .positive() .optional() .catch(20) .default(20), page: z.coerce .number() // for prettier formatting .int() .min(0) .optional() .catch(1) .default(1), query: z.string().optional(), sort_by: z .enum(["megabytesIn", "megabytesOut"]) .optional() .catch(undefined), order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc"), online: z .enum(["true", "false"]) .transform((v) => v === "true") .optional() .catch(undefined), agent: z .enum([ "windows", "android", "cli", "macos", "ios", "ipados", "unknown" ]) .optional() .catch(undefined), filters: z.preprocess( (val: string) => { return val.split(","); // the search query array is an array joined by a comma }, z .array( z.enum(["active", "pending", "denied", "blocked", "archived"]) ) .optional() .default(["active", "pending"]) .catch(["active", "pending"]) ) }); function queryUserDevicesBase() { return db .select({ clientId: clients.clientId, orgId: clients.orgId, name: clients.name, pubKey: clients.pubKey, subnet: clients.subnet, megabytesIn: clients.megabytesIn, megabytesOut: clients.megabytesOut, orgName: orgs.name, type: clients.type, online: clients.online, olmVersion: olms.version, userId: clients.userId, username: users.username, userEmail: users.email, niceId: clients.niceId, agent: olms.agent, approvalState: clients.approvalState, olmArchived: olms.archived, archived: clients.archived, blocked: clients.blocked, deviceModel: currentFingerprint.deviceModel, fingerprintPlatform: currentFingerprint.platform, fingerprintOsVersion: currentFingerprint.osVersion, fingerprintKernelVersion: currentFingerprint.kernelVersion, fingerprintArch: currentFingerprint.arch, fingerprintSerialNumber: currentFingerprint.serialNumber, fingerprintUsername: currentFingerprint.username, fingerprintHostname: currentFingerprint.hostname }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) .leftJoin(olms, eq(clients.clientId, olms.clientId)) .leftJoin(users, eq(clients.userId, users.userId)) .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)); } type OlmWithUpdateAvailable = Awaited< ReturnType >[0] & { olmUpdateAvailable?: boolean; }; export type ListUserDevicesResponse = PaginatedResponse<{ devices: Array; }>; registry.registerPath({ method: "get", path: "/org/{orgId}/user-devices", description: "List all user devices for an organization.", tags: [OpenAPITags.Client, OpenAPITags.Org], request: { query: listUserDevicesSchema, params: listUserDevicesParamsSchema }, responses: {} }); export async function listUserDevices( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = listUserDevicesSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const { page, pageSize, query, sort_by, online, filters, agent, order } = parsedQuery.data; const parsedParams = listUserDevicesParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error) ) ); } const { orgId } = parsedParams.data; if (req.user && orgId && orgId !== req.userOrgId) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } let accessibleClients; if (req.user) { accessibleClients = await db .select({ clientId: sql`COALESCE(${userClients.clientId}, ${roleClients.clientId})` }) .from(userClients) .fullJoin( roleClients, eq(userClients.clientId, roleClients.clientId) ) .where( or( eq(userClients.userId, req.user!.userId), eq(roleClients.roleId, req.userOrgRoleId!) ) ); } else { accessibleClients = await db .select({ clientId: clients.clientId }) .from(clients) .where(eq(clients.orgId, orgId)); } const accessibleClientIds = accessibleClients.map( (client) => client.clientId ); // Get client count with filter const conditions = [ and( inArray(clients.clientId, accessibleClientIds), eq(clients.orgId, orgId), isNotNull(clients.userId) ) ]; if (query) { conditions.push( or( ilike(clients.name, "%" + query + "%"), ilike(users.name, "%" + query + "%") ) ); } if (typeof online !== "undefined") { conditions.push(eq(clients.online, online)); } const agentValueMap = { windows: "Pangolin Windows", android: "Pangolin Android", ios: "Pangolin iOS", ipados: "Pangolin iPadOS", macos: "Pangolin macOS", cli: "Pangolin CLI" } satisfies Record< Exclude, string >; if (typeof agent !== "undefined") { if (agent === "unknown") { conditions.push(isNull(olms.agent)); } else { conditions.push(eq(olms.agent, agentValueMap[agent])); } } if (filters.length > 0) { const filterAggregates: (SQL | undefined)[] = []; if (filters.includes("active")) { filterAggregates.push( and( eq(clients.archived, false), eq(clients.blocked, false), or( eq(clients.approvalState, "approved"), isNull(clients.approvalState) // approval state of `NULL` means approved by default ) ) ); } if (filters.includes("pending")) { filterAggregates.push(eq(clients.approvalState, "pending")); } if (filters.includes("denied")) { filterAggregates.push(eq(clients.approvalState, "denied")); } if (filters.includes("archived")) { filterAggregates.push(eq(clients.archived, true)); } if (filters.includes("blocked")) { filterAggregates.push(eq(clients.blocked, true)); } conditions.push(or(...filterAggregates)); } const baseQuery = queryUserDevicesBase().where(and(...conditions)); const countQuery = db.$count(baseQuery.as("filtered_clients")); const listDevicesQuery = baseQuery .limit(pageSize) .offset(pageSize * (page - 1)) .orderBy( sort_by ? order === "asc" ? asc(clients[sort_by]) : desc(clients[sort_by]) : asc(clients.clientId) ); const [clientsList, totalCount] = await Promise.all([ listDevicesQuery, countQuery ]); // Merge clients with their site associations and replace name with device name const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsList.map( (client) => { const model = client.deviceModel || null; const newName = getUserDeviceName(model, client.name); const OlmWithUpdate: OlmWithUpdateAvailable = { ...client, name: newName }; // Initially set to false, will be updated if version check succeeds OlmWithUpdate.olmUpdateAvailable = false; return OlmWithUpdate; } ); // Try to get the latest version, but don't block if it fails try { const latestOlmVersion = await getLatestOlmVersion(); if (latestOlmVersion) { olmsWithUpdates.forEach((client) => { try { client.olmUpdateAvailable = semver.lt( client.olmVersion ? client.olmVersion : "", latestOlmVersion ); } catch (error) { client.olmUpdateAvailable = false; } }); } } catch (error) { // Log the error but don't let it block the response logger.warn( "Failed to check for OLM updates, continuing without update info:", error ); } return response(res, { data: { devices: olmsWithUpdates, pagination: { total: totalCount, page, pageSize } }, success: true, error: false, message: "Clients retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } }