Merge pull request #3869 from bluewave-labs/fix/proxy-scoping
fix: proxy scoping
This commit is contained in:
16 files changed
+1224
-20
No files matched your search
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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<SettingsResponse>("/settings");
|
||||
const { data: fetchedSettings } = useGet<AppSettingsResponse>("/settings");
|
||||
// Get proxies
|
||||
const { data: proxies } = useGet<ProxyResponse[]>("/proxies/team");
|
||||
const { data: proxies } = useGet<ProxyResponse[]>("/proxies");
|
||||
const proxyOptions = useMemo(
|
||||
() =>
|
||||
(proxies ?? []).map((proxy) => ({
|
||||
@@ -79,7 +74,7 @@ export const SettingsPage = () => {
|
||||
);
|
||||
|
||||
// Form submission
|
||||
const { patch, loading: isSaving } = usePatch<SettingsFormData, SettingsResponse>();
|
||||
const { patch, loading: isSaving } = usePatch<SettingsFormData, AppSettingsResponse>();
|
||||
|
||||
// Local state for API key reset
|
||||
const [isApiKeySet, setIsApiKeySet] = useState(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+1034
File diff suppressed because it is too large.
Load diff
@@ -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)." },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown> = { ...dbSettings };
|
||||
delete sanitizedSettings.version;
|
||||
delete sanitizedSettings.jwtSecret;
|
||||
const globalProxy =
|
||||
dbSettings.globalProxyEnabled && dbSettings.globalProxyId ? await this.proxiesService.getProxySummary(dbSettings.globalProxyId) : null;
|
||||
const returnSettings: Record<string, unknown | null> = {
|
||||
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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string, unknown> => {
|
||||
if (filter === undefined) {
|
||||
return {};
|
||||
}
|
||||
switch (filter) {
|
||||
case "up":
|
||||
return { status: true };
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface IProxiesRepository {
|
||||
// create
|
||||
create(proxyData: Partial<Proxy>): Promise<Proxy>;
|
||||
// read
|
||||
findAll(): Promise<Proxy[]>;
|
||||
findById(proxyId: string, teamId: string): Promise<Proxy>;
|
||||
findByIdOrNull(proxyId: string, teamId?: string): Promise<Proxy | null>;
|
||||
findByTeamId(teamId: string): Promise<Proxy[]>;
|
||||
|
||||
@@ -37,6 +37,11 @@ class MongoProxiesRepository implements IProxiesRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(): Promise<Proxy[]> {
|
||||
const proxies = await ProxyModel.find();
|
||||
return proxies.map(this.toEntity);
|
||||
}
|
||||
|
||||
async findById(proxyId: string, teamId: string): Promise<Proxy> {
|
||||
const proxy = await ProxyModel.findOne({ _id: proxyId, teamId });
|
||||
if (!proxy) {
|
||||
|
||||
@@ -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<Proxy>, teamId: string): Promise<ProxyResponse>;
|
||||
getProxy(proxyId: string, teamId: string): Promise<ProxyResponse>;
|
||||
getProxies(): Promise<ProxyResponse[]>;
|
||||
getProxySummary(proxyId: string): Promise<ProxySummary | null>;
|
||||
getProxiesByTeamId(teamId: string): Promise<ProxyResponse[]>;
|
||||
updateProxy(proxyId: string, teamId: string, patch: Partial<Proxy> & { clearPassword?: boolean; clearUsername?: boolean }): Promise<ProxyResponse>;
|
||||
deleteProxy(proxyId: string, teamId: string): Promise<ProxyResponse>;
|
||||
@@ -39,6 +41,23 @@ export class ProxiesService implements IProxiesService {
|
||||
return this.toResponse(raw);
|
||||
};
|
||||
|
||||
getProxies = async (): Promise<ProxyResponse[]> => {
|
||||
const raw = await this.proxiesRepository.findAll();
|
||||
const clean = raw.map((r) => {
|
||||
return this.toResponse(r);
|
||||
});
|
||||
return clean;
|
||||
};
|
||||
|
||||
getProxySummary = async (proxyId: string): Promise<ProxySummary | null> => {
|
||||
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<ProxyResponse[]> => {
|
||||
const raw = await this.proxiesRepository.findByTeamId(teamId);
|
||||
const clean = raw.map((r) => {
|
||||
|
||||
@@ -15,3 +15,5 @@ export interface Proxy {
|
||||
}
|
||||
|
||||
export type ProxyResponse = Omit<Proxy, "password"> & { hasPassword: boolean };
|
||||
|
||||
export type ProxySummary = Pick<Proxy, "id" | "name" | "host" | "port">;
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user