diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 6d19ae25b..5c2b693e0 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -484,10 +484,7 @@ const CreateMonitorPage = () => { })), [t] ); - const globalProxyName = - appSettings?.settings?.globalProxyEnabled === true - ? proxies?.find((proxy) => proxy.id === appSettings?.settings?.globalProxyId)?.name - : undefined; + const globalProxyName = appSettings?.globalProxy?.name; const proxyModeOptions = useMemo( () => diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 3377182af..70a8e1bb6 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -45,12 +45,7 @@ import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; import type { ProxyResponse } from "@/Types/Proxy"; - -interface SettingsResponse { - settings: any; - pagespeedKeySet: boolean; - emailPasswordSet: boolean; -} +import type { AppSettingsResponse } from "@/Types/Settings"; export const SettingsPage = () => { const theme = useTheme(); @@ -66,9 +61,9 @@ export const SettingsPage = () => { const { post: importMonitors, loading: isImportingMonitors } = usePost(); // Fetch settings data from API - const { data: fetchedSettings } = useGet("/settings"); + const { data: fetchedSettings } = useGet("/settings"); // Get proxies - const { data: proxies } = useGet("/proxies/team"); + const { data: proxies } = useGet("/proxies"); const proxyOptions = useMemo( () => (proxies ?? []).map((proxy) => ({ @@ -79,7 +74,7 @@ export const SettingsPage = () => { ); // Form submission - const { patch, loading: isSaving } = usePatch(); + const { patch, loading: isSaving } = usePatch(); // Local state for API key reset const [isApiKeySet, setIsApiKeySet] = useState( diff --git a/client/src/Types/Settings.ts b/client/src/Types/Settings.ts index 22cbb6a8d..01d34aead 100644 --- a/client/src/Types/Settings.ts +++ b/client/src/Types/Settings.ts @@ -33,5 +33,11 @@ export interface Settings { export interface AppSettingsResponse { pagespeedKeySet: boolean; emailPasswordSet: boolean; + globalProxy: { + id: string; + name: string; + host: string; + port: number; + } | null; settings: Settings; } diff --git a/server/openapi.json b/server/openapi.json index 759fca9d7..c2408b580 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -48,6 +48,10 @@ "name": "settings", "description": "Global application settings (admin/superadmin)." }, + { + "name": "proxies", + "description": "Outbound proxy servers for routing HTTP monitor checks, team-scoped with an instance-wide admin listing." + }, { "name": "invite", "description": "Team invitations and accepting them." @@ -373,6 +377,13 @@ "ignoreTlsErrors": { "type": "boolean" }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"] + }, + "proxyId": { + "type": "string" + }, "useAdvancedMatching": { "type": "boolean" }, @@ -486,6 +497,7 @@ "statusWindowSize", "statusWindowThreshold", "ignoreTlsErrors", + "proxyMode", "useAdvancedMatching", "method", "notifications", @@ -580,6 +592,13 @@ "ignoreTlsErrors": { "type": "boolean" }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"] + }, + "proxyId": { + "type": "string" + }, "useAdvancedMatching": { "type": "boolean" }, @@ -695,6 +714,7 @@ "statusWindowSize", "statusWindowThreshold", "ignoreTlsErrors", + "proxyMode", "useAdvancedMatching", "method", "notifications", @@ -7785,6 +7805,14 @@ "type": "boolean", "default": false }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"], + "default": "inherit" + }, + "proxyId": { + "type": "string" + }, "useAdvancedMatching": { "type": "boolean", "default": false @@ -8432,6 +8460,14 @@ "type": "boolean", "default": false }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"], + "default": "inherit" + }, + "proxyId": { + "type": "string" + }, "useAdvancedMatching": { "type": "boolean", "default": false @@ -9050,6 +9086,13 @@ "ignoreTlsErrors": { "type": "boolean" }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"] + }, + "proxyId": { + "type": "string" + }, "useAdvancedMatching": { "type": "boolean" }, @@ -10210,6 +10253,990 @@ } } }, + "/proxies": { + "post": { + "tags": ["proxies"], + "summary": "Create a proxy for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string", + "minLength": 1 + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": ["name", "protocol", "host", "port"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "get": { + "tags": ["proxies"], + "summary": "List all proxies on the instance (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/proxies/team": { + "get": { + "tags": ["proxies"], + "summary": "List proxies for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/proxies/{id}": { + "get": { + "tags": ["proxies"], + "summary": "Get a proxy by id", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "patch": { + "tags": ["proxies"], + "summary": "Edit a proxy", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string", + "minLength": 1 + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "clearPassword": { + "type": "boolean" + }, + "clearUsername": { + "type": "boolean" + } + }, + "required": ["name", "protocol", "host", "port"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "delete": { + "tags": ["proxies"], + "summary": "Delete a proxy", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "409": { + "description": "Proxy is in use by monitors or set as the global proxy", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, "/queue/jobs": { "get": { "tags": ["queue"], @@ -10839,6 +11866,13 @@ "maximum": 150 } } + }, + "globalProxyEnabled": { + "type": "boolean" + }, + "globalProxyId": { + "type": "string", + "nullable": true } } } diff --git a/server/openapi/index.ts b/server/openapi/index.ts index c96cec233..a50008a75 100644 --- a/server/openapi/index.ts +++ b/server/openapi/index.ts @@ -9,6 +9,7 @@ import "./routes/log.js"; import "./routes/maintenanceWindow.js"; import "./routes/monitor.js"; import "./routes/notification.js"; +import "./routes/proxies.js"; import "./routes/queue.js"; import "./routes/settings.js"; import "./routes/statusPage.js"; @@ -45,6 +46,7 @@ export function getOpenApiSpec(): JsonObject { { name: "maintenance-window", description: "Scheduled maintenance windows during which monitors do not generate incidents or alerts." }, { name: "status-page", description: "Public status pages, their configuration, and the monitors they expose." }, { name: "settings", description: "Global application settings (admin/superadmin)." }, + { name: "proxies", description: "Outbound proxy servers for routing HTTP monitor checks, team-scoped with an instance-wide admin listing." }, { name: "invite", description: "Team invitations and accepting them." }, { name: "queue", description: "Background job queue introspection and admin actions (admin/superadmin)." }, { name: "diagnostic", description: "System diagnostics for the running server (admin/superadmin)." }, diff --git a/server/openapi/routes/proxies.ts b/server/openapi/routes/proxies.ts new file mode 100644 index 000000000..0a742fcf1 --- /dev/null +++ b/server/openapi/routes/proxies.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { registry } from "../registry.js"; +import { bearer, json, okJson, errorEnvelope, standardErrors } from "../helpers.js"; +import { + createProxyBodyValidation, + editProxyBodyValidation, + getProxyByIdParamValidation, + editProxyParamValidation, + deleteProxyParamValidation, + proxyResponseSchema, +} from "@/api/validation/proxyValidation.js"; + +const tags = ["proxies"]; + +registry.registerPath({ + method: "post", + path: "/proxies", + tags, + summary: "Create a proxy for the caller's team", + security: bearer, + request: { body: { content: json(createProxyBodyValidation) } }, + responses: { "200": okJson(proxyResponseSchema), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/proxies", + tags, + summary: "List all proxies on the instance (admin/superadmin)", + security: bearer, + responses: { "200": okJson(z.array(proxyResponseSchema)), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/proxies/team", + tags, + summary: "List proxies for the caller's team", + security: bearer, + responses: { "200": okJson(z.array(proxyResponseSchema)), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/proxies/{id}", + tags, + summary: "Get a proxy by id", + security: bearer, + request: { params: getProxyByIdParamValidation }, + responses: { "200": okJson(proxyResponseSchema), ...standardErrors }, +}); + +registry.registerPath({ + method: "patch", + path: "/proxies/{id}", + tags, + summary: "Edit a proxy", + security: bearer, + request: { params: editProxyParamValidation, body: { content: json(editProxyBodyValidation) } }, + responses: { "200": okJson(proxyResponseSchema), ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/proxies/{id}", + tags, + summary: "Delete a proxy", + security: bearer, + request: { params: deleteProxyParamValidation }, + responses: { + "200": okJson(proxyResponseSchema), + "409": { description: "Proxy is in use by monitors or set as the global proxy", content: json(errorEnvelope) }, + ...standardErrors, + }, +}); diff --git a/server/src/api/controllers/proxyController.ts b/server/src/api/controllers/proxyController.ts index 0ca72ed95..d509a490a 100644 --- a/server/src/api/controllers/proxyController.ts +++ b/server/src/api/controllers/proxyController.ts @@ -13,6 +13,7 @@ import { export interface IProxiesController { createProxy: RequestHandler; getProxyById: RequestHandler; + getAllProxies: RequestHandler; getProxiesByTeamId: RequestHandler; editProxy: RequestHandler; deleteProxy: RequestHandler; @@ -43,6 +44,15 @@ class ProxyController implements IProxiesController { }); }); + getAllProxies = catchAsync(async (req: Request, res: Response) => { + const proxies = await this.proxiesService.getProxies(); + return res.status(200).json({ + success: true, + msg: "Proxies retrieved successfully", + data: proxies, + }); + }); + getProxiesByTeamId = catchAsync(async (req: Request, res: Response) => { const teamId = requireTeamId(req.user?.teamId); const proxies = await this.proxiesService.getProxiesByTeamId(teamId); diff --git a/server/src/api/controllers/settingsController.ts b/server/src/api/controllers/settingsController.ts index a8113ed2b..d02c3886a 100644 --- a/server/src/api/controllers/settingsController.ts +++ b/server/src/api/controllers/settingsController.ts @@ -5,6 +5,7 @@ import { sendTestEmailBodyValidation } from "@/api/validation/notificationValida import { AppError } from "@/utils/AppError.js"; import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { IEmailService } from "@/service/emailService.js"; +import { IProxiesService } from "@/domain/proxies/proxy.service.js"; import { Settings } from "@/domain/app-settings/app-settings.type.js"; export interface ISettingsController { @@ -16,18 +17,23 @@ export interface ISettingsController { class SettingsController implements ISettingsController { private settingsService: ISettingsService; private emailService: IEmailService; - constructor(settingsService: ISettingsService, emailService: IEmailService) { + private proxiesService: IProxiesService; + constructor(settingsService: ISettingsService, emailService: IEmailService, proxiesService: IProxiesService) { this.settingsService = settingsService; this.emailService = emailService; + this.proxiesService = proxiesService; } - buildAppSettings = (dbSettings: Settings) => { + buildAppSettings = async (dbSettings: Settings) => { const sanitizedSettings: Record = { ...dbSettings }; delete sanitizedSettings.version; delete sanitizedSettings.jwtSecret; + const globalProxy = + dbSettings.globalProxyEnabled && dbSettings.globalProxyId ? await this.proxiesService.getProxySummary(dbSettings.globalProxyId) : null; const returnSettings: Record = { pagespeedKeySet: false, emailPasswordSet: false, + globalProxy, settings: null, }; @@ -46,7 +52,7 @@ class SettingsController implements ISettingsController { getAppSettings = catchAsync(async (req: Request, res: Response) => { const dbSettings = await this.settingsService.getDBSettings(); - const returnSettings = this.buildAppSettings(dbSettings); + const returnSettings = await this.buildAppSettings(dbSettings); return res.status(200).json({ success: true, msg: "App settings fetched successfully", @@ -57,8 +63,15 @@ class SettingsController implements ISettingsController { updateAppSettings = catchAsync(async (req: Request, res: Response) => { const validatedBody = updateAppSettingsBodyValidation.parse(req.body); + if (validatedBody.globalProxyId) { + const proxy = await this.proxiesService.getProxySummary(validatedBody.globalProxyId); + if (!proxy) { + throw new AppError({ message: "Referenced proxy does not exist", status: 422 }); + } + } + const updatedSettings = await this.settingsService.updateDbSettings(validatedBody); - const returnSettings = this.buildAppSettings(updatedSettings); + const returnSettings = await this.buildAppSettings(updatedSettings); return res.status(200).json({ success: true, msg: "App settings updated successfully", diff --git a/server/src/api/routes/proxyRoutes.ts b/server/src/api/routes/proxyRoutes.ts index 83bf6a318..fca695099 100644 --- a/server/src/api/routes/proxyRoutes.ts +++ b/server/src/api/routes/proxyRoutes.ts @@ -1,9 +1,11 @@ import { IProxiesController } from "@/api/controllers/proxyController.js"; +import { isAllowed } from "@/api/middleware/isAllowed.js"; import { Router } from "express"; export const createProxyRoutes = (proxyController: IProxiesController): Router => { const router = Router(); router.post("/", proxyController.createProxy); + router.get("/", isAllowed(["admin", "superadmin"]), proxyController.getAllProxies); router.get("/team", proxyController.getProxiesByTeamId); router.get("/:id", proxyController.getProxyById); router.delete("/:id", proxyController.deleteProxy); diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts index 4f683babf..7787a389e 100644 --- a/server/src/config/controllers.ts +++ b/server/src/config/controllers.ts @@ -36,7 +36,7 @@ export const initializeControllers = (apiServices: ApiServices): InitializedCont return { authController: new AuthController(apiServices.userService), monitorController: new MonitorController(apiServices.monitorService, apiServices.notificationsService), - settingsController: new SettingsController(apiServices.settingsService, apiServices.emailService), + settingsController: new SettingsController(apiServices.settingsService, apiServices.emailService, apiServices.proxiesService), checkController: new CheckController(apiServices.checkService), geoCheckController: new GeoCheckController(apiServices.geoChecksService), inviteController: new InviteController(apiServices.inviteService), diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index de144ea80..93ba2c42b 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -25,7 +25,7 @@ import { CheckFilter, DateRange } from "@/types/query.js"; import { AppError } from "@/utils/AppError.js"; import { NETWORK_ERROR } from "@/types/network.js"; -const SERVICE_NAME = "StatusService"; +const SERVICE_NAME = "ChecksRepository"; class MongoChecksRepository implements IChecksRepository { static SERVICE_NAME = SERVICE_NAME; @@ -213,6 +213,9 @@ class MongoChecksRepository implements IChecksRepository { }; private filterToMatch = (filter: CheckFilter | undefined): Record => { + if (filter === undefined) { + return {}; + } switch (filter) { case "up": return { status: true }; diff --git a/server/src/domain/proxies/proxy.repository.interface.ts b/server/src/domain/proxies/proxy.repository.interface.ts index 5d39ddb80..12d145ef3 100644 --- a/server/src/domain/proxies/proxy.repository.interface.ts +++ b/server/src/domain/proxies/proxy.repository.interface.ts @@ -4,6 +4,7 @@ export interface IProxiesRepository { // create create(proxyData: Partial): Promise; // read + findAll(): Promise; findById(proxyId: string, teamId: string): Promise; findByIdOrNull(proxyId: string, teamId?: string): Promise; findByTeamId(teamId: string): Promise; diff --git a/server/src/domain/proxies/proxy.repository.mongo.ts b/server/src/domain/proxies/proxy.repository.mongo.ts index 454ef68ab..cf11fd458 100644 --- a/server/src/domain/proxies/proxy.repository.mongo.ts +++ b/server/src/domain/proxies/proxy.repository.mongo.ts @@ -37,6 +37,11 @@ class MongoProxiesRepository implements IProxiesRepository { } } + async findAll(): Promise { + const proxies = await ProxyModel.find(); + return proxies.map(this.toEntity); + } + async findById(proxyId: string, teamId: string): Promise { const proxy = await ProxyModel.findOne({ _id: proxyId, teamId }); if (!proxy) { diff --git a/server/src/domain/proxies/proxy.service.ts b/server/src/domain/proxies/proxy.service.ts index 265544d8c..9758f2856 100644 --- a/server/src/domain/proxies/proxy.service.ts +++ b/server/src/domain/proxies/proxy.service.ts @@ -1,4 +1,4 @@ -import { Proxy, ProxyResponse } from "@/domain/proxies/proxy.type.js"; +import { Proxy, ProxyResponse, ProxySummary } from "@/domain/proxies/proxy.type.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js"; import { AppError } from "@/utils/AppError.js"; @@ -7,6 +7,8 @@ import { ISettingsService } from "@/domain/app-settings/app-settings.service.js" export interface IProxiesService { createProxy(proxy: Partial, teamId: string): Promise; getProxy(proxyId: string, teamId: string): Promise; + getProxies(): Promise; + getProxySummary(proxyId: string): Promise; getProxiesByTeamId(teamId: string): Promise; updateProxy(proxyId: string, teamId: string, patch: Partial & { clearPassword?: boolean; clearUsername?: boolean }): Promise; deleteProxy(proxyId: string, teamId: string): Promise; @@ -39,6 +41,23 @@ export class ProxiesService implements IProxiesService { return this.toResponse(raw); }; + getProxies = async (): Promise => { + const raw = await this.proxiesRepository.findAll(); + const clean = raw.map((r) => { + return this.toResponse(r); + }); + return clean; + }; + + getProxySummary = async (proxyId: string): Promise => { + const proxy = await this.proxiesRepository.findByIdOrNull(proxyId); + if (!proxy) { + return null; + } + const { id, name, host, port } = proxy; + return { id, name, host, port }; + }; + getProxiesByTeamId = async (teamId: string): Promise => { const raw = await this.proxiesRepository.findByTeamId(teamId); const clean = raw.map((r) => { diff --git a/server/src/domain/proxies/proxy.type.ts b/server/src/domain/proxies/proxy.type.ts index dd098dee3..c16b0c28c 100644 --- a/server/src/domain/proxies/proxy.type.ts +++ b/server/src/domain/proxies/proxy.type.ts @@ -15,3 +15,5 @@ export interface Proxy { } export type ProxyResponse = Omit & { hasPassword: boolean }; + +export type ProxySummary = Pick; diff --git a/server/test/unit/services/proxiesService.test.ts b/server/test/unit/services/proxiesService.test.ts index 038754f28..b2c89ba7d 100644 --- a/server/test/unit/services/proxiesService.test.ts +++ b/server/test/unit/services/proxiesService.test.ts @@ -6,7 +6,9 @@ import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts"; const createProxiesRepo = () => ({ create: jest.fn(), + findAll: jest.fn(), findById: jest.fn(), + findByIdOrNull: jest.fn(), findByTeamId: jest.fn(), updateById: jest.fn(), deleteById: jest.fn(), @@ -105,6 +107,44 @@ describe("ProxiesService", () => { }); }); + describe("getProxies", () => { + it("lists every proxy on the instance and masks passwords", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findAll as jest.Mock).mockResolvedValue([makeProxy(), makeProxy({ id: "proxy-2", teamId: "team-2", password: undefined })]); + + const result = await service.getProxies(); + + expect(proxiesRepository.findAll).toHaveBeenCalledWith(); + expect(result).toHaveLength(2); + for (const proxy of result) { + expect(proxy).not.toHaveProperty("password"); + } + expect(result[0].hasPassword).toBe(true); + expect(result[1].hasPassword).toBe(false); + }); + }); + + describe("getProxySummary", () => { + it("looks up without a team filter and returns only display fields", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findByIdOrNull as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.getProxySummary("proxy-1"); + + expect(proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("proxy-1"); + expect(result).toEqual({ id: "proxy-1", name: "Egress proxy", host: "proxy.internal", port: 3128 }); + }); + + it("returns null when the proxy does not exist", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findByIdOrNull as jest.Mock).mockResolvedValue(null); + + const result = await service.getProxySummary("missing"); + + expect(result).toBeNull(); + }); + }); + describe("getProxiesByTeamId", () => { it("masks every proxy in the list", async () => { const { service, proxiesRepository } = createService();