Compare commits

...
Author SHA1 Message Date
gorkem-bwl a672f7604d fix(monitors): bound certificate probe and gate client on scheme
Addresses review feedback on #3848.

Timeout: the probe now dials a user-defined port, so it can reach a
service that accepts the connection but never completes the TLS
handshake, hanging the request indefinitely. ssl-checker already wires
a "timeout" listener that rejects with "Timed Out", but its defaults
set no timeout, so it never fired. Passing one activates it.

The bundled type declaration is a hand-maintained subset of
https.RequestOptions and omitted "timeout"; it is added there so the
call typechecks against an option the library already supports.

Client gating: "http" is a single monitor type covering both schemes,
so an http:// monitor passed the existing type check and requested a
certificate anyway. With the server now rejecting those, the details
page would surface a 400 on every visit. The scheme is gated client
side to match, and the stat box falls back to "N/A" as before.

Test comment removed as requested; the timeout is covered by a new
assertion in the same file.
2026-08-25 18:14:25 -04:00
gorkem-bwl 25a7ac4877 fix(monitors): reject certificate lookups for plain-HTTP monitors
Passing the monitor's own port to ssl-checker exposed an adjacent gap found
in review: a plain-HTTP monitor on a non-default port, such as
http://host:8080, now had a TLS handshake attempted against a port that
serves no TLS. That surfaced to the client as a 500 carrying a raw OpenSSL
error ("packet length too long") instead of the previous silent "N/A".

The endpoint never checked the scheme — it was already wrong for http://
monitors, just quietly so, since every such lookup went to port 443. Guard
on the protocol and fail with a 400 before opening a socket.

Adds tests for both plain-HTTP cases (default and custom port), each
asserting the checker is never invoked. Verified by removing the guard:
both fail without it.
2026-08-25 18:14:25 -04:00
gorkem-bwl f74d1d53f6 fix(monitors): check TLS certificate on the monitor's own port
fetchMonitorCertificate parsed the monitor URL but passed only the hostname
to ssl-checker, which then defaulted to 443. A monitor on a non-standard
port such as https://host:54321 therefore had the certificate of whatever
answered on 443 inspected instead of its own, surfacing as "N/A" in the
certificate expiry box when nothing was listening there.

URL.port is an empty string for default ports, so it is mapped to 443
explicitly rather than passed through Number(), which would have yielded
NaN. ssl-checker already accepts the port via SSLOptions, so no dependency
or signature change is needed.

Adds a regression suite covering the reported case (non-standard port), the
default-port fallback, an explicit :443, and the NaN guard. Verified by
reverting the fix: the three port assertions fail without it.

Fixes #3646
2026-08-25 18:14:25 -04:00
Alexander Holliday 959a434d21 Merge pull request #3875 from bluewave-labs/fix/package-lock-sync
fix package lock sync issue
2026-08-25 15:06:03 -07:00
Alex Holliday 4298397e3e fix package lock sync issue 2026-08-25 15:05:28 -07:00
Alexander Holliday fc3fab4905 Merge pull request #3873 from bluewave-labs/chore/version-bump
minor version bump
2026-08-25 14:52:58 -07:00
Alex Holliday 0b54b5c6cd minor version bump 2026-08-25 14:49:52 -07:00
Alexander Holliday 358b1deec7 Merge pull request #3872 from bluewave-labs/fix/csp
fix: logo image preview CSP
2026-08-25 14:48:00 -07:00
Alex Holliday 9839d48ca0 add img-src blob: to CSP, stop refetching blob 2026-08-25 14:44:01 -07:00
Alexander Holliday 83c2df4390 Merge pull request #3863 from eralpozcan/feat/status-page-monitor-tags
feat(status-page): show monitor tags in status page selector
2026-08-25 14:16:33 -07:00
Alexander Holliday 31f6727519 Merge pull request #3871 from bluewave-labs/fix/remove-dead-workflows
fix: remove dead workflows
2026-08-25 14:12:02 -07:00
Alex Holliday 96f8bf4a66 Merge branch 'develop' into fix/remove-dead-workflows 2026-08-25 14:10:18 -07:00
Alexander Holliday 06bf5e45f5 Merge pull request #3870 from bluewave-labs/fix/package-lock
fix: regen package lock
2026-08-25 14:09:34 -07:00
Alex Holliday d0fc402683 format 2026-08-25 14:08:11 -07:00
Alex Holliday b922654cfc remove poeditor workflows 2026-08-25 14:07:13 -07:00
Alex Holliday d7dce5d7a3 regen package lock 2026-08-25 14:03:35 -07:00
Alexander Holliday 5d2aa28570 Merge pull request #3869 from bluewave-labs/fix/proxy-scoping
fix: proxy scoping
2026-08-25 13:54:05 -07:00
Alex Holliday af3c72b055 add tests 2026-08-25 11:19:33 -07:00
Alex Holliday b3e98ac95d update openapi docs 2026-08-25 11:14:50 -07:00
Alex Holliday 3f62c8ec6a resolve scoping issue by fetching proxy summary 2026-08-25 11:13:12 -07:00
Alex Holliday b2dca7b62f add getAllProxies route 2026-08-25 11:02:37 -07:00
Alexander Holliday f731648007 Merge pull request #3868 from bluewave-labs/feat/proxy-frontend
add global proxy setting config and translation keys
2026-08-25 10:48:04 -07:00
Alexander Holliday a1199f957d Merge pull request #3867 from bluewave-labs/feat/proxy-frontend
feat: proxy frontend
2026-08-25 10:44:46 -07:00
Alex Holliday ea8ad88ae9 add global proxy setting config and translation keys 2026-08-25 10:29:19 -07:00
Alex Holliday 6e271f834c select -> radio, update descriptions 2026-08-25 09:55:34 -07:00
Alex Holliday 0c21dcdd2a add settings response type, destructure 2026-08-25 09:39:56 -07:00
eralpozcan 9bb95e1f0c feat: show monitor tags in status page selector 2026-08-22 14:05:44 +03:00
Alex Holliday ccfda938d5 Add proxies to create/edit monitors page 2026-08-21 15:13:34 -07:00
Alex Holliday cc137e1d06 add proxies CRUD pages 2026-08-21 15:13:17 -07:00
Alexander Holliday 41859f241a Merge pull request #3860 from bluewave-labs/feat/proxy-resolver
feat: proxy resolver
2026-08-21 12:34:22 -07:00
Alex Holliday 252067142e apply proxy agents to http monitors 2026-08-21 12:19:23 -07:00
Alex Holliday 1152a70afb wire up proxy resolver 2026-08-21 11:41:26 -07:00
Alex Holliday ffd7ea7517 add tests 2026-08-21 11:13:22 -07:00
Alex Holliday a56bff2305 add proxy resolver 2026-08-21 11:06:42 -07:00
Alexander Holliday db05db0c0c Merge pull request #3859 from bluewave-labs/feat/outbound-proxy-server-crud
feat: outbound proxy server crud
2026-08-21 10:54:42 -07:00
Alex Holliday cd520dacc5 add tests 2026-08-20 14:38:18 -07:00
Alex Holliday 637bb5cca9 add db migration for proxy mode inherit 2026-08-20 14:31:23 -07:00
Alex Holliday ede93add64 add clear username flag 2026-08-20 13:57:24 -07:00
Alex Holliday 4443a53a23 strip password 2026-08-20 13:52:14 -07:00
Alex Holliday 1b2d2887af add proxy routes/controller 2026-08-20 13:35:11 -07:00
Alex Holliday 54690fc73c add/update validations 2026-08-20 13:34:56 -07:00
Alex Holliday 6e3252aec8 wire repo and service 2026-08-20 11:31:51 -07:00
Alex Holliday edd9335e96 add gloabl proxy to app settings 2026-08-20 11:16:07 -07:00
Alex Holliday cf13bc329e add proxy service 2026-08-20 11:00:05 -07:00
Alex Holliday 80097afb71 update monitor model/repos 2026-08-20 10:59:40 -07:00
Alex Holliday 5435167e73 add proxy repository and interface 2026-08-20 10:49:12 -07:00
Alex Holliday 059dc2737b add proxy type and model 2026-08-20 10:28:50 -07:00
Alexander Holliday 158f2fffbe Merge pull request #3858 from bluewave-labs/feat/uptime-percentage-two-decimals
feat(client): show uptime percentages with two decimals
2026-08-20 09:39:56 -07:00
gorkem-bwl 68c1f2e281 feat(client): show uptime percentages with two decimals
Requested by a user who found xx.xx% more trustworthy than xx.x% on their
status page. The presentational argument holds, but the stronger reason is
that one decimal was actively misleading.

Intl rounds to the displayed precision, so every value above 99.95% rendered
as "100.0%" — a claim of zero downtime for a period that contained real
outages. On a public status page that is the opposite of reassuring: the
number reads as flawless during a month with a visible incident.

One decimal also collapsed the range operators care about most. 99.9%,
99.95% and 99.99% are three very different commitments (8.7 hours, 4.4 hours
and 52 minutes of annual downtime) and the first rendered as 99.9% while the
other two were indistinguishable at 100.0%.

Everything flows through the shared formatter in FormatUtils, so this covers
both surfaces at once: the monitor list and diagnostics gauges in the web
app, and the uptime figure, chart cells and hover tooltip on status pages.
Infrastructure gauges are untouched — they format separately at zero
decimals, where CPU at "43.00%" would only add noise.

Server-side precision was already sufficient; the aggregation divides
without rounding, so no API change is needed.

Adds tests pinning the regression: 7 of the 10 fail against the previous
one-decimal formatter.

Note: this restores the client Vitest setup. PR #3844 introduced it, but the
config, scripts and logger tests were dropped when that branch merged into
develop, leaving the client with no test runner again.
2026-08-20 09:38:07 -07:00
79 changed files with 5522 additions and 1812 deletions

No files matched your search

-138
View File
@@ -1,138 +0,0 @@
name: POEditor Translation Synchronization (disabled)
# This workflow is intentionally disabled — kept for reference only.
# The full definition is preserved commented-out below. To re-enable,
# delete the stub job and uncomment the section starting at `on:`.
on:
workflow_dispatch:
jobs:
disabled:
if: false
runs-on: ubuntu-latest
steps:
- run: echo "This workflow is disabled. See comments below for the original definition."
# ---------------------------------------------------------------------------
# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing.
# ---------------------------------------------------------------------------
# on:
# # For manual triggering
# workflow_dispatch:
# inputs:
# languages:
# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
# required: false
# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
# format:
# description: "Export format (key_value_json or json)"
# required: false
# default: "key_value_json"
# permissions:
# contents: write
# pull-requests: write
# jobs:
# sync-translations:
# runs-on: ubuntu-latest
# permissions:
# contents: write
# issues: write
# pull-requests: write
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# fetch-depth: 0
# - name: Setup Node.js
# uses: actions/setup-node@v3
# with:
# node-version: "22"
# - name: Create package.json for scripts
# run: |
# mkdir -p .github/scripts
# cat > .github/scripts/package.json << EOF
# {
# "name": "poeditor-scripts",
# "version": "1.0.0",
# "private": true,
# "type": "module",
# "dependencies": {
# "axios": "^1.6.0",
# "fs-extra": "^11.1.1"
# }
# }
# EOF
# - name: Install dependencies
# run: |
# cd .github/scripts
# npm install
# - name: Download translations from POEditor
# env:
# POEDITOR_API: ${{ secrets.POEDITOR_API }}
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
# run: |
# mkdir -p temp
# node .github/scripts/download-translations.js
# - name: Verify translation files
# run: |
# echo "Verifying translation files..."
# for file in temp/*.json; do
# echo "Checking $file"
# if [ ! -s "$file" ]; then
# echo "Error: $file is empty or does not exist"
# exit 1
# fi
# # Validate JSON format
# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
# done
# echo "All translation files are valid"
# - name: Copy translations to project
# run: |
# mkdir -p client/src/locales
# cp -r temp/* client/src/locales/
# echo "Translation files copied to client/src/locales/"
# - name: Get current date
# id: date
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
# - name: Format client code
# run: |
# cd client
# npm ci
# npm run format
# - name: Create Pull Request
# uses: peter-evans/create-pull-request@v7
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# commit-message: "feat: translations updated from POEditor"
# title: "🌐 Updated Translations from POEditor"
# body: |
# This PR contains the latest translations from POEditor.
# 📅 Update Date: ${{ steps.date.outputs.date }}
# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
# ⚠️ Please review the translations and approve the PR if everything looks correct.
# branch: translation-update-${{ github.run_number }}
# delete-branch: true
# base: develop
# add-paths: |
# client/src/locales/*.json
# committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
-113
View File
@@ -1,113 +0,0 @@
name: Upload Translations to POEditor on PR Merge
on:
pull_request:
types: [closed]
branches:
- develop
paths:
- "client/src/locales/**"
jobs:
upload-translations:
# Only run if the PR was merged (not just closed) or manually triggered
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0 # Fetch all history to get changed files
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "22"
- name: Create package.json for scripts
run: |
mkdir -p .github/scripts
cat > .github/scripts/package.json << EOF
{
"name": "poeditor-scripts",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"axios": "^1.6.0",
"fs-extra": "^11.1.1",
"form-data": "^4.0.0"
}
}
EOF
- name: Install dependencies
run: |
cd .github/scripts
npm install
- name: Get changed locale files
id: changed-files
if: github.event_name == 'pull_request'
run: |
# Get base and head commits
BASE_SHA=${{ github.event.pull_request.base.sha }}
HEAD_SHA=${{ github.event.pull_request.head.sha }}
echo "Base SHA: $BASE_SHA"
echo "Head SHA: $HEAD_SHA"
# Get list of changed files in client/src/locales directory
CHANGED_FILES=$(git diff --name-only $BASE_SHA..$HEAD_SHA -- 'client/src/locales/*.json' || git ls-files 'client/src/locales/*.json')
if [ -z "$CHANGED_FILES" ]; then
echo "No changes detected in locale files"
echo "CHANGED_FILES=" >> $GITHUB_ENV
else
echo "Changed files:"
echo "$CHANGED_FILES"
echo "CHANGED_FILES<<EOF" >> $GITHUB_ENV
echo "$CHANGED_FILES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
fi
- name: Upload changed translations to POEditor
if: env.CHANGED_FILES != ''
env:
POEDITOR_API: ${{ secrets.POEDITOR_API }}
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
run: |
# Process each changed file
for FILE in $CHANGED_FILES; do
if [[ -f "$FILE" ]]; then
# Extract language code from filename (e.g., client/src/locales/en.json -> en)
FILENAME=$(basename "$FILE")
# Special case: map gb.json to en language code
if [ "$FILENAME" == "gb.json" ]; then
LANG="en"
echo "Found gb.json, mapping to language code 'en'"
else
LANG=$(basename "$FILE" .json)
fi
echo "Processing $FILE for language $LANG"
# Upload to POEditor
LANGUAGE=$LANG FILE_PATH=$FILE node .github/scripts/upload-translations.js
fi
done
- name: Notify on success
if: success() && env.CHANGED_FILES != ''
run: |
echo "Successfully uploaded translation files to POEditor."
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "Manual trigger comment: ${{ github.event.inputs.comment }}"
fi
- name: Notify on no changes
if: env.CHANGED_FILES == ''
run: |
echo "No translation files were found to upload."
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "client",
"version": "3.10.0",
"version": "3.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
"version": "3.10.0",
"version": "3.11.0",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "3.10.0",
"version": "3.11.0",
"type": "module",
"scripts": {
"dev": "vite",
+15 -4
View File
@@ -11,10 +11,11 @@ interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
fieldLabel?: string;
required?: boolean;
placeholder?: string;
helperText?: string;
}
const SelectInputInner = <T,>(
{ fieldLabel, required, placeholder, ...props }: SelectInputProps<T>,
{ fieldLabel, required, placeholder, helperText, ...props }: SelectInputProps<T>,
ref: React.ForwardedRef<HTMLDivElement>
) => {
const theme = useTheme();
@@ -27,7 +28,7 @@ const SelectInputInner = <T,>(
: selected === undefined || selected === null || selected === "";
if (isEmpty && placeholder) {
return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
return <Typography color={emptyPlaceholderColor}>{placeholder}</Typography>;
}
if (isMultiple) {
@@ -92,11 +93,21 @@ const SelectInputInner = <T,>(
/>
);
if (fieldLabel) {
const helper = helperText ? (
<Typography
variant="caption"
color={props.error ? theme.palette.error.main : theme.palette.text.secondary}
>
{helperText}
</Typography>
) : null;
if (fieldLabel || helper) {
return (
<Stack spacing={theme.spacing(2)}>
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
{select}
{helper}
</Stack>
);
}
@@ -8,6 +8,7 @@ import {
} from "react-hook-form";
import { LAYOUT } from "@/Utils/Theme/constants";
import { RadioWithDescription } from "@/Components/inputs/Radio";
import { FieldLabel } from "@/Components/inputs/FieldLabel";
export interface FormRadioOption {
value: string;
label: string;
@@ -17,10 +18,12 @@ export interface FormRadioOption {
interface FormRadioGroupProps<T extends FieldValues> {
name: FieldPath<T>;
options: FormRadioOption[];
fieldLabel?: string;
}
export const FormRadioGroup = <T extends FieldValues>({
name,
options,
fieldLabel,
}: FormRadioGroupProps<T>) => {
const { control } = useFormContext<T>();
const theme = useTheme();
@@ -30,6 +33,7 @@ export const FormRadioGroup = <T extends FieldValues>({
control={control}
render={({ field, fieldState }) => (
<FormControl error={!!fieldState.error}>
{fieldLabel && <FieldLabel>{fieldLabel}</FieldLabel>}
<RadioGroup
{...field}
sx={{ gap: theme.spacing(LAYOUT.MD) }}
@@ -44,6 +44,7 @@ export const FormSelectField = <T extends FieldValues>({
}}
error={!!fieldState.error}
{...rest}
helperText={fieldState.error?.message ?? rest.helperText}
>
{options.map((option) => (
<MenuItem
+6 -1
View File
@@ -18,8 +18,8 @@ import {
Lock,
Users,
Tag,
Waypoints,
} from "lucide-react";
export const getMenu = (t: Function) => {
return [
{
@@ -47,6 +47,11 @@ export const getMenu = (t: Function) => {
path: "tags",
icon: <Icon icon={Tag} />,
},
{
name: t("components.sidebar.menu.proxies"),
path: "proxies",
icon: <Icon icon={Waypoints} />,
},
{
name: t("components.sidebar.menu.checks"),
path: "checks",
+5
View File
@@ -4,6 +4,7 @@ import {
DefaultHttpMethod,
DefaultMonitorMatchMethod,
DefaultPageSpeedStrategy,
DefaultProxyMode,
} from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor";
@@ -46,6 +47,8 @@ export const getMonitorDefaults = (
url: data?.url || "",
method: data?.method ?? DefaultHttpMethod,
ignoreTlsErrors: data?.ignoreTlsErrors || false,
proxyMode: data?.proxyMode ?? DefaultProxyMode,
proxyId: data?.proxyId || "",
useAdvancedMatching: data?.useAdvancedMatching || false,
matchMethod: data?.matchMethod || DefaultMonitorMatchMethod,
expectedValue: data?.expectedValue || "",
@@ -141,6 +144,8 @@ export const getMonitorDefaults = (
url: "",
method: DefaultHttpMethod,
ignoreTlsErrors: false,
proxyMode: DefaultProxyMode,
proxyId: "",
useAdvancedMatching: false,
matchMethod: DefaultMonitorMatchMethod,
expectedValue: "",
+22
View File
@@ -0,0 +1,22 @@
import { useMemo } from "react";
import { proxySchema, type ProxyFormData } from "@/Validation/proxy";
import type { ProxyResponse } from "@/Types/Proxy";
interface UseProxiesFormOptions {
data?: ProxyResponse | null;
}
export const useProxiesForm = ({ data }: UseProxiesFormOptions) => {
return useMemo(() => {
const defaults: ProxyFormData = {
name: data?.name ?? "",
protocol: data?.protocol ?? "http",
host: data?.host ?? "",
port: data?.port ?? 8080,
username: "",
password: "",
};
return { schema: proxySchema, defaults };
}, [data]);
};
+2
View File
@@ -43,6 +43,8 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
checkTTL: data?.checkTTL ?? 30,
pagespeedApiKey: "",
systemEmailPassword: "",
globalProxyEnabled: data?.globalProxyEnabled ?? false,
globalProxyId: data?.globalProxyId ?? null,
};
return { schema: settingsSchema, defaults };
+76 -5
View File
@@ -41,9 +41,12 @@ import {
DefaultMonitorMatchMethod,
MonitorMatchMethods,
GeoCheckIntervalOptions,
ProxyModes,
type ProxyMode,
} from "@/Types/Monitor";
import type { Notification } from "@/Types/Notification";
import type { Tag } from "@/Types/Tag";
import type { AppSettingsResponse } from "@/Types/Settings";
import {
stepFieldsFor,
monitorStepCount,
@@ -56,6 +59,7 @@ import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectF
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
import type { ProxyResponse } from "@/Types/Proxy";
const httpMethodOptions = HttpMethods.map((method) => ({
value: method,
@@ -67,6 +71,7 @@ interface GeneralSettingsConfig {
urlPlaceholder: string;
namePlaceholder: string;
showUrl: boolean;
showProxy: boolean;
showPort: boolean;
showGameSelect: boolean;
showSecret: boolean;
@@ -87,6 +92,7 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: true,
showPort: false,
showGameSelect: false,
showSecret: false,
@@ -101,6 +107,7 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: false,
showGameSelect: false,
showSecret: false,
@@ -115,6 +122,7 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: false,
showGameSelect: false,
showSecret: false,
@@ -129,6 +137,7 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: true,
showGameSelect: false,
showSecret: false,
@@ -143,6 +152,7 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: true,
showGameSelect: true,
showSecret: false,
@@ -157,6 +167,7 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: true,
showGameSelect: false,
showSecret: false,
@@ -171,6 +182,7 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: false,
showGameSelect: false,
showSecret: false,
@@ -185,6 +197,7 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: false,
showGameSelect: false,
showSecret: true,
@@ -199,6 +212,7 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.wsUrl.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: false,
showGameSelect: false,
showSecret: false,
@@ -213,6 +227,7 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: false,
showGameSelect: false,
showSecret: false,
@@ -258,6 +273,8 @@ const CreateMonitorPage = () => {
const { data: notifications } = useGet<Notification[]>("/notifications/team");
const { data: games } = useGet<GamesMap>("/monitors/games");
const { data: tags } = useGet<Tag[]>("/tags/team");
const { data: proxies } = useGet<ProxyResponse[]>("/proxies/team");
const { data: appSettings } = useGet<AppSettingsResponse>("/settings");
const { schema, defaults } = useMonitorForm({
data: existingMonitor ?? null,
@@ -281,6 +298,7 @@ const CreateMonitorPage = () => {
const watchedType = watch("type") as MonitorType;
const watchedMethod = watch("method") as HttpMethod | undefined;
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
@@ -466,6 +484,38 @@ const CreateMonitorPage = () => {
})),
[t]
);
const globalProxyName = appSettings?.globalProxy?.name;
const proxyModeOptions = useMemo(
() =>
ProxyModes.map((mode) => {
const key =
mode === "inherit"
? globalProxyName
? "inheritGlobal"
: "inheritDirect"
: mode;
return {
value: mode,
label: t(`pages.createMonitor.form.general.option.proxyMode.value.${key}`, {
name: globalProxyName,
}),
description: t(
`pages.createMonitor.form.general.option.proxyMode.description.${key}`
),
};
}),
[t, globalProxyName]
);
const proxyOptions = useMemo(
() =>
(proxies ?? []).map((proxy) => ({
value: proxy.id,
label: `${proxy.name} (${proxy.host}:${proxy.port})`,
})),
[proxies]
);
return (
<FormProvider {...form}>
@@ -518,6 +568,11 @@ const CreateMonitorPage = () => {
}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="name"
fieldLabel={t("pages.createMonitor.form.general.option.name.label")}
placeholder={generalSettingsConfig.namePlaceholder}
/>
{/* URL/Host/Container field - not shown for hardware */}
{generalSettingsConfig.showUrl && (
<FormTextField
@@ -527,6 +582,27 @@ const CreateMonitorPage = () => {
disabled={isEditMode}
/>
)}
{/* Proxy fields - only shown for HTTP */}
{generalSettingsConfig.showProxy && (
<>
<FormRadioGroup
name="proxyMode"
options={proxyModeOptions}
fieldLabel={t(
"pages.createMonitor.form.general.option.proxyMode.label"
)}
/>
{watchedProxyMode === "custom" && (
<FormSelectField
name="proxyId"
fieldLabel={t(
"pages.createMonitor.form.general.option.proxy.label"
)}
options={proxyOptions}
/>
)}
</>
)}
{/* Strategy field - only for pagespeed type */}
{generalSettingsConfig.showStrategy && (
@@ -603,11 +679,6 @@ const CreateMonitorPage = () => {
options={dnsRecordTypeOptions}
/>
)}
<FormTextField
name="name"
fieldLabel={t("pages.createMonitor.form.general.option.name.label")}
placeholder={generalSettingsConfig.namePlaceholder}
/>
</Stack>
}
/>
@@ -0,0 +1,103 @@
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import Typography from "@mui/material/Typography";
import type { Header } from "@/Components/design-elements/Table";
import { Table } from "@/Components/design-elements";
import { Pagination } from "@/Components/design-elements/Table";
import { useClientPagination } from "@/Hooks/useClientPagination";
import type { ProxyResponse } from "@/Types/Proxy";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
interface ProxiesTableProps {
proxies: ProxyResponse[];
setSelectedProxy: Function;
}
export const ProxiesTable = ({ proxies, setSelectedProxy }: ProxiesTableProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
const theme = useTheme();
const { pagedRows, paginationProps } = useClientPagination(proxies);
const getActions = (proxy: ProxyResponse): ActionMenuItem[] => {
return [
{
id: 1,
label: t("pages.common.monitors.actions.configure"),
action: () => {
navigate(`/proxies/configure/${proxy.id}`);
},
closeMenu: true,
},
{
id: 2,
label: (
<Typography color={theme.palette.error.main}>
{t("pages.common.monitors.actions.delete")}
</Typography>
),
action: async () => {
setSelectedProxy(proxy);
},
closeMenu: true,
},
];
};
const getHeaders = () => {
const headers: Header<ProxyResponse>[] = [
{
id: "name",
content: t("common.table.headers.name"),
render: (row) => {
return <Typography>{row?.name}</Typography>;
},
},
{
id: "protocol",
content: t("pages.proxies.table.headers.protocol"),
render: (row) => {
return <Typography>{row?.protocol}</Typography>;
},
},
{
id: "host",
content: t("pages.proxies.table.headers.host"),
render: (row) => {
return <Typography>{row?.host}</Typography>;
},
},
{
id: "port",
content: t("pages.proxies.table.headers.port"),
render: (row) => {
return <Typography>{row?.port}</Typography>;
},
},
{
id: "actions",
content: t("common.table.headers.actions"),
render: (row) => {
return <ActionsMenu items={getActions(row)} />;
},
},
];
return headers;
};
return (
<>
<Table
headers={getHeaders()}
data={pagedRows}
onRowClick={(row) => {
navigate(`/proxies/configure/${row.id}`);
}}
/>
{proxies.length > 0 && <Pagination {...paginationProps} />}
</>
);
};
+213
View File
@@ -0,0 +1,213 @@
import { BasePage, ConfigBox } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { Button, FieldLabel } from "@/Components/inputs";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { mutate } from "swr";
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import type { ProxyFormData } from "@/Validation/proxy";
import { ProxyProtocols, type ProxyResponse } from "@/Types/Proxy";
import { useTranslation } from "react-i18next";
import { useProxiesForm } from "@/Hooks/useProxiesForm";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import { useMemo } from "react";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
const ProxiesCreatePage = () => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
const { proxyId } = useParams<{ proxyId: string }>();
const isEditMode = Boolean(proxyId);
const { data: existingProxy } = useGet<ProxyResponse>(
isEditMode ? `/proxies/${proxyId}` : null
);
const { post, loading: isSubmitting } = usePost<ProxyFormData, ProxyResponse>();
const { patch, loading: isPatching } = usePatch<ProxyFormData, ProxyResponse>();
const { schema, defaults } = useProxiesForm({ data: existingProxy });
const form = useForm<ProxyFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { reset, handleSubmit } = form;
const isPasswordSet = existingProxy?.hasPassword ?? false;
const [passwordHasBeenReset, setPasswordHasBeenReset] = useState(false);
const handleResetPassword = () => {
form.setValue("password", "");
setPasswordHasBeenReset(true);
};
const isUsernameSet = Boolean(existingProxy?.username);
const [usernameHasBeenReset, setUsernameHasBeenReset] = useState(false);
const handleResetUsername = () => {
form.setValue("username", "");
setUsernameHasBeenReset(true);
};
const protocolOptions = useMemo(
() =>
ProxyProtocols.map((protocol) => ({
value: protocol,
label: protocol,
})),
[]
);
useEffect(() => {
reset(defaults);
}, [defaults, reset]);
const onSubmit = async (data: ProxyFormData) => {
const payload: ProxyFormData & { clearPassword?: boolean; clearUsername?: boolean } =
{
...data,
};
// Don't send password/username if one is set and the user hasn't clicked reset
if (isPasswordSet && !passwordHasBeenReset) {
delete payload.password;
} else if (passwordHasBeenReset && !payload.password) {
payload.clearPassword = true;
}
if (isUsernameSet && !usernameHasBeenReset) {
delete payload.username;
} else if (usernameHasBeenReset && !payload.username) {
payload.clearUsername = true;
}
const result = isEditMode
? await patch(`/proxies/${proxyId}`, payload)
: await post("/proxies", payload);
if (result?.success) {
await mutate((key) => typeof key === "string" && key.startsWith("/proxies"));
navigate("/proxies");
}
};
return (
<FormProvider {...form}>
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.proxies.form.name.title")}
subtitle={t("pages.proxies.form.name.description")}
rightContent={
<FormTextField
name="name"
fieldLabel={t("pages.proxies.form.name.optionName")}
placeholder={t("pages.proxies.form.name.placeholder")}
/>
}
/>
<ConfigBox
title={t("pages.proxies.form.server.title")}
subtitle={t("pages.proxies.form.server.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<FormSelectField
name="protocol"
fieldLabel={t("pages.proxies.form.server.option.protocol.label")}
options={protocolOptions}
/>
<FormTextField
name="host"
fieldLabel={t("pages.proxies.form.server.option.host.label")}
placeholder={t("pages.proxies.form.server.option.host.placeholder")}
/>
<FormNumberField
name="port"
fieldLabel={t("pages.proxies.form.server.option.port.label")}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.proxies.form.auth.title")}
subtitle={t("pages.proxies.form.auth.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
{(isUsernameSet === false || usernameHasBeenReset === true) && (
<FormTextField
name="username"
fieldLabel={t("pages.proxies.form.auth.option.username.label")}
placeholder={t("pages.proxies.form.auth.option.username.placeholder")}
/>
)}
{isUsernameSet === true && usernameHasBeenReset === false && (
<Box>
<FieldLabel>
{t("pages.proxies.form.auth.option.username.labelSet")}
</FieldLabel>
<Button
onClick={handleResetUsername}
variant="contained"
color="error"
>
{t("common.buttons.reset")}
</Button>
</Box>
)}
{(isPasswordSet === false || passwordHasBeenReset === true) && (
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.proxies.form.auth.option.password.label")}
/>
)}
{isPasswordSet === true && passwordHasBeenReset === false && (
<Box>
<FieldLabel>
{t("pages.proxies.form.auth.option.password.labelSet")}
</FieldLabel>
<Button
onClick={handleResetPassword}
variant="contained"
color="error"
>
{t("common.buttons.reset")}
</Button>
</Box>
)}
</Stack>
}
/>
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
>
<Button
loading={isSubmitting || isPatching}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
</FormProvider>
);
};
export default ProxiesCreatePage;
+72
View File
@@ -0,0 +1,72 @@
import { BasePageWithStates } from "@/Components/design-elements";
import { ProxiesTable } from "@/Pages/Proxies/components/ProxiesTable";
import { Dialog } from "@/Components/inputs";
import { HeaderCreate } from "@/Components/common";
import { useState } from "react";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next";
import type { ProxyResponse } from "@/Types/Proxy";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
const ProxiesPage = () => {
const { t } = useTranslation();
const isAdmin = useIsAdmin();
const [selectedProxy, setSelectedProxy] = useState<ProxyResponse | null>(null);
const isDialogOpen = Boolean(selectedProxy);
const {
data: proxies,
isLoading,
isValidating,
error,
refetch,
} = useGet<ProxyResponse[]>("/proxies/team", {}, { keepPreviousData: true });
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirm = async () => {
if (!selectedProxy) return;
await deleteFn(`/proxies/${selectedProxy.id}`);
setSelectedProxy(null);
refetch();
};
const handleCancel = () => {
setSelectedProxy(null);
};
return (
<BasePageWithStates
headerKey="proxies"
page={t("pages.proxies.fallback.title")}
description={t("pages.proxies.fallback.description")}
loading={isLoading || isValidating}
error={!!error}
totalCount={proxies?.length ?? 0}
actionButtonText={t("pages.proxies.fallback.actionButton")}
actionLink="/proxies/create"
>
<HeaderCreate
path="/proxies/create"
isLoading={isLoading || isValidating}
isAdmin={isAdmin}
/>
<ProxiesTable
proxies={proxies ?? []}
setSelectedProxy={setSelectedProxy}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</BasePageWithStates>
);
};
export default ProxiesPage;
+66 -9
View File
@@ -10,7 +10,7 @@ import {
Alert,
type SelectChangeEvent,
} from "@mui/material";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { FormProvider, useForm, type FieldPath } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
@@ -43,12 +43,9 @@ import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
interface SettingsResponse {
settings: any;
pagespeedKeySet: boolean;
emailPasswordSet: boolean;
}
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import type { ProxyResponse } from "@/Types/Proxy";
import type { AppSettingsResponse } from "@/Types/Settings";
export const SettingsPage = () => {
const theme = useTheme();
@@ -64,9 +61,20 @@ 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");
const proxyOptions = useMemo(
() =>
(proxies ?? []).map((proxy) => ({
value: proxy.id,
label: `${proxy.name} (${proxy.host}:${proxy.port})`,
})),
[proxies]
);
// 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(
@@ -115,6 +123,8 @@ export const SettingsPage = () => {
mode: "onChange",
});
const { watch } = form;
// Reset form when defaults change
useEffect(() => {
form.reset(defaults);
@@ -292,6 +302,21 @@ export const SettingsPage = () => {
const languages = Object.keys(i18n.options.resources || {});
const globalProxyEnabled = watch("globalProxyEnabled");
// Enabling the proxy with nothing selected preselects the first proxy
useEffect(() => {
if (
globalProxyEnabled &&
!form.getValues("globalProxyId") &&
proxyOptions.length > 0
) {
form.setValue("globalProxyId", proxyOptions[0].value, {
shouldValidate: true,
});
}
}, [globalProxyEnabled, proxyOptions, form]);
return (
<FormProvider {...form}>
<BasePage
@@ -365,6 +390,38 @@ export const SettingsPage = () => {
</Stack>
}
/>
{isAdmin && (
<ConfigBox
title={t("pages.settings.form.globalProxy.title")}
subtitle={t("pages.settings.form.globalProxy.description")}
rightContent={
proxyOptions.length > 0 ? (
<Stack>
<FormSwitchField
name="globalProxyEnabled"
labelPlacement="start"
label={t("pages.settings.form.globalProxy.option.enabled.label")}
/>
{globalProxyEnabled && (
<FormSelectField
fieldLabel={t(
"pages.settings.form.globalProxy.option.proxy.label"
)}
name="globalProxyId"
options={proxyOptions}
/>
)}
</Stack>
) : (
<TextLink
text={t("pages.settings.form.globalProxy.option.empty.text")}
linkText={t("pages.settings.form.globalProxy.option.empty.link")}
href="/proxies"
/>
)
}
/>
)}
{isAdmin && (
<ConfigBox
title={t("pages.settings.form.pagespeed.title")}
+36 -15
View File
@@ -1,4 +1,9 @@
import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements";
import {
BasePage,
ColoredLabel,
ConfigBox,
StepProgress,
} from "@/Components/design-elements";
import Stack from "@mui/material/Stack";
import { logger } from "@/Utils/logger";
import { LAYOUT } from "@/Utils/Theme/constants";
@@ -16,12 +21,12 @@ import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statu
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import { SelectableMonitorTypes } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { timezoneOptions } from "@/Utils/timezoneOptions";
import { useNavigate, useParams } from "react-router-dom";
import { mutate } from "swr";
import axios from "axios";
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
import { ThemePicker } from "./Components/ThemePicker";
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
@@ -53,7 +58,9 @@ const LogoUploadField = () => {
<ImageUpload
src={field.value?.data}
onChange={(file) => {
field.onChange(file ? { data: file.src, contentType: file.file.type } : null);
field.onChange(
file ? { data: file.src, contentType: file.file.type, file: file.file } : null
);
}}
/>
);
@@ -86,6 +93,8 @@ const CreateStatusPage = () => {
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
const monitors = useMemo(() => monitorsResponse ?? [], [monitorsResponse]);
const { data: tagsResponse } = useGet<Tag[]>("/tags/team");
const tags = useMemo(() => tagsResponse ?? [], [tagsResponse]);
const { post, loading: isSubmittingPost } = usePost();
const { put, loading: isSubmittingPut } = usePut();
@@ -196,18 +205,8 @@ const CreateStatusPage = () => {
if (data.logo === null) {
// Signal to remove the logo
fd.append("removeLogo", "true");
} else if (data.logo?.data && data.logo.data !== "") {
if (data.logo.data.startsWith("blob:")) {
try {
const imageResult = await axios.get(data.logo.data, {
responseType: "blob",
});
fd.append("logo", imageResult.data);
URL.revokeObjectURL(data.logo.data);
} catch (e) {
logger.error("Failed to fetch logo blob", e instanceof Error ? e : undefined);
}
}
} else if (data.logo?.file) {
fd.append("logo", data.logo.file);
}
let result;
@@ -308,6 +307,28 @@ const CreateStatusPage = () => {
"pages.statusPages.form.monitors.option.monitors.placeholder"
)}
options={monitors}
renderOptionContent={(monitor) => (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
flexWrap="wrap"
>
<Typography>
{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}
</Typography>
{monitor.tags.map((tagId) => {
const tag = tags.find(({ id }) => id === tagId);
return tag ? (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
) : null;
})}
</Stack>
)}
renderRow={(monitor) => (
<Typography flexGrow={1}>
{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}
+16 -3
View File
@@ -34,6 +34,17 @@ import { Typography } from "@mui/material";
const certificateDateFormat = "MMM D, YYYY h A";
const isHttpsUrl = (url?: string): boolean => {
if (!url) {
return false;
}
try {
return new URL(url).protocol === "https:";
} catch {
return false;
}
};
interface CertificateResponse {
certificateDate: string;
}
@@ -80,13 +91,15 @@ const UptimeDetailsPage = () => {
const monitor = monitorData?.monitor;
const monitorStats = monitorDetailsData?.monitorStats ?? null;
// Certificate fetch - only for HTTP monitors
// Certificate fetch - only for monitors served over HTTPS. A "http" type
// monitor may still have an http:// URL, which serves no certificate, so the
// scheme is gated here to match the server rather than requesting a 400.
const certificateUrl = useMemo(() => {
if (!monitorId || monitor?.type !== "http") {
if (!monitorId || monitor?.type !== "http" || !isHttpsUrl(monitor?.url)) {
return null;
}
return `/monitors/certificate/${monitorId}`;
}, [monitorId, monitor?.type]);
}, [monitorId, monitor?.type, monitor?.url]);
const { data: certificateData } = useGet<CertificateResponse>(
certificateUrl,
+17 -4
View File
@@ -39,11 +39,14 @@ import CreateNotifications from "@/Pages/Notifications/create";
import Tags from "@/Pages/Tags";
import CreateTags from "@/Pages/Tags/create";
// Proxies
import Proxies from "@/Pages/Proxies";
import CreateProxies from "@/Pages/Proxies/create";
// Settings
import Account from "@/Pages/Account";
import EditUser from "@/Pages/Account/EditUser";
import Settings from "@/Pages/Settings";
import Maintenance from "@/Pages/Maintenance";
import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create";
@@ -52,10 +55,8 @@ import Logs from "@/Pages/Logs";
// Routing
import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected";
import CreateMonitor from "@/Pages/CreateMonitor";
import { isCustomDomainHost } from "@/Utils/statusPageUrl";
const Routes = () => {
if (isCustomDomainHost()) {
return (
@@ -188,12 +189,24 @@ const Routes = () => {
path="tags/create"
element={<CreateTags />}
/>
<Route
path="tags/configure/:tagId"
element={<CreateTags />}
/>
<Route
path="proxies"
element={<Proxies />}
/>
<Route
path="proxies/create"
element={<CreateProxies />}
/>
<Route
path="proxies/configure/:proxyId"
element={<CreateProxies />}
/>
<Route
path="maintenance"
element={<Maintenance />}
+6
View File
@@ -109,6 +109,10 @@ export const HttpMethods = ["GET", "HEAD"] as const;
export type HttpMethod = (typeof HttpMethods)[number];
export const DefaultHttpMethod: HttpMethod = "GET";
export const ProxyModes = ["inherit", "none", "custom"] as const;
export type ProxyMode = (typeof ProxyModes)[number];
export const DefaultProxyMode: ProxyMode = "inherit";
export interface Monitor {
id: string;
userId: string;
@@ -121,6 +125,8 @@ export interface Monitor {
statusWindowThreshold: number;
type: MonitorType;
ignoreTlsErrors: boolean;
proxyMode: ProxyMode;
proxyId?: string;
useAdvancedMatching: boolean;
jsonPath?: string;
expectedValue?: string;
+17
View File
@@ -0,0 +1,17 @@
export const ProxyProtocols = ["http", "https"] as const;
export type ProxyProtocol = (typeof ProxyProtocols)[number];
export interface Proxy {
id: string;
teamId: string;
name: string;
protocol: ProxyProtocol;
host: string;
port: number;
username?: string;
password?: string;
createdAt: string;
updatedAt: string;
}
export type ProxyResponse = Omit<Proxy, "password"> & { hasPassword: boolean };
+8
View File
@@ -24,6 +24,8 @@ export interface Settings {
showURL: boolean;
singleton: boolean;
globalThresholds?: SettingsThresholds;
globalProxyEnabled: boolean;
globalProxyId?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -31,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;
}
+17 -11
View File
@@ -1,25 +1,31 @@
/**
* Intl.NumberFormat instance for percentage formatting.
* Reused across all formatting calls for performance.
*
* Two decimals rather than one: at a single decimal every value above 99.95%
* renders as "100.0%", so a period containing real downtime is reported as
* flawless. It also collapses 99.9 / 99.95 / 99.99 — three materially
* different SLA commitments — into the same figure.
*/
const percentageFormatter = new Intl.NumberFormat("en-US", {
style: "percent",
minimumFractionDigits: 1,
maximumFractionDigits: 1,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
/**
* Formats a decimal value as a percentage string.
* @param value - Decimal value (e.g., 0.75 for 75%)
* @returns Formatted percentage string (e.g., "75.0%")
* @returns Formatted percentage string (e.g., "75.00%")
* @example
* formatPercentage(0.75) // "75.0%"
* formatPercentage(1) // "100.0%"
* formatPercentage(0.5432) // "54.3%"
* formatPercentage(0.75) // "75.00%"
* formatPercentage(1) // "100.00%"
* formatPercentage(0.5432) // "54.32%"
* formatPercentage(0.9999) // "99.99%"
*/
export const formatPercentage = (value: number): string => {
if (typeof value !== "number" || Number.isNaN(value)) {
return "0.0%";
return "0.00%";
}
return percentageFormatter.format(value);
};
@@ -27,11 +33,11 @@ export const formatPercentage = (value: number): string => {
/**
* Formats a whole number percentage value as a percentage string.
* @param value - Whole number percentage (e.g., 75 for 75%)
* @returns Formatted percentage string (e.g., "75.0%")
* @returns Formatted percentage string (e.g., "75.00%")
* @example
* formatPercentageFromWhole(75) // "75.0%"
* formatPercentageFromWhole(100) // "100.0%"
* formatPercentageFromWhole(54.32) // "54.3%"
* formatPercentageFromWhole(75) // "75.00%"
* formatPercentageFromWhole(100) // "100.00%"
* formatPercentageFromWhole(54.32) // "54.32%"
*/
export const formatPercentageFromWhole = (value: number): string => {
return formatPercentage(value / 100);
+16 -2
View File
@@ -5,6 +5,7 @@ import {
DnsRecordTypes,
HttpMethods,
PageSpeedStrategies,
ProxyModes,
type MonitorType,
} from "@/Types/Monitor";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
@@ -67,6 +68,8 @@ const httpStatusCode = z.number().refine((code) => httpStatusCodeSet.has(code),
const httpSchema = baseSchema.extend({
type: z.literal("http"),
url: urlSchema,
proxyMode: z.enum(ProxyModes),
proxyId: z.string().optional(),
method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }),
ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }),
useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }),
@@ -195,7 +198,7 @@ const dnsSchema = baseSchema.extend({
});
// Discriminated union of all monitor types
export const monitorSchema = z.discriminatedUnion("type", [
const monitorSchemaUnion = z.discriminatedUnion("type", [
httpSchema,
pingSchema,
portSchema,
@@ -208,10 +211,21 @@ export const monitorSchema = z.discriminatedUnion("type", [
dnsSchema,
]);
// Mirrors the server's refineProxySelection (monitorValidation.ts)
export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) {
ctx.addIssue({
code: "custom",
path: ["proxyId"],
message: "A proxy must be selected when proxy mode is custom",
});
}
});
export type MonitorFormData = z.infer<typeof monitorSchema>;
const optionForType = (type: MonitorType) =>
monitorSchema.options.find((o) => o.shape.type.value === type);
monitorSchemaUnion.options.find((o) => o.shape.type.value === type);
// The step each field of a type is validated on, from the per-field
// `monitorStepRegistry` metadata (unannotated fields default to step 0).
+17
View File
@@ -0,0 +1,17 @@
import { z } from "zod";
import { ProxyProtocols } from "@/Types/Proxy";
export const proxySchema = z.object({
name: z.string().min(1, "Proxy name is required"),
protocol: z.enum(ProxyProtocols, "Invalid proxy protocol"),
host: z.string().min(1, "Proxy host is required"),
port: z
.number()
.int()
.min(1, "Proxy port is required")
.max(65535, "Proxy port must be between 1 and 65535"),
username: z.string().optional(),
password: z.string().optional(),
});
export type ProxyFormData = z.infer<typeof proxySchema>;
+67 -55
View File
@@ -1,61 +1,73 @@
import { CHECK_TTL_SENTINEL } from "@/Types/Check";
import { z } from "zod";
export const settingsSchema = z.object({
systemEmailIgnoreTLS: z.boolean(),
systemEmailRequireTLS: z.boolean(),
systemEmailRejectUnauthorized: z.boolean(),
systemEmailConnectionHost: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(),
showURL: z.boolean().optional(),
checkTTL: z
.number()
.int()
.min(1, "Please enter a value")
.max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`),
pagespeedApiKey: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailHost: z
.string()
.regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address")
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailPort: z.number().int().min(1).max(65535).optional(),
systemEmailAddress: z
.email("Please enter a valid email address")
.or(z.literal(""))
.transform((val) => (val === "" ? null : val.toLowerCase().trim()))
.optional(),
systemEmailDisplayName: z
.string()
.max(100, "Display name must be 100 characters or fewer")
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailUser: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailPassword: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailTLSServername: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
globalThresholds: z.object({
cpu: z.number().int().min(1).max(100),
memory: z.number().int().min(1).max(100),
disk: z.number().int().min(1).max(100),
temperature: z.number().int().min(1).max(150),
}),
});
export const settingsSchema = z
.object({
systemEmailIgnoreTLS: z.boolean(),
systemEmailRequireTLS: z.boolean(),
systemEmailRejectUnauthorized: z.boolean(),
systemEmailConnectionHost: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(),
showURL: z.boolean().optional(),
checkTTL: z
.number()
.int()
.min(1, "Please enter a value")
.max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`),
pagespeedApiKey: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailHost: z
.string()
.regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address")
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailPort: z.number().int().min(1).max(65535).optional(),
systemEmailAddress: z
.email("Please enter a valid email address")
.or(z.literal(""))
.transform((val) => (val === "" ? null : val.toLowerCase().trim()))
.optional(),
systemEmailDisplayName: z
.string()
.max(100, "Display name must be 100 characters or fewer")
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailUser: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailPassword: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailTLSServername: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
globalThresholds: z.object({
cpu: z.number().int().min(1).max(100),
memory: z.number().int().min(1).max(100),
disk: z.number().int().min(1).max(100),
temperature: z.number().int().min(1).max(150),
}),
globalProxyEnabled: z.boolean(),
globalProxyId: z.string().nullable().optional(),
})
.superRefine((body, ctx) => {
if (body.globalProxyEnabled === true && !body.globalProxyId) {
ctx.addIssue({
code: "custom",
path: ["globalProxyId"],
message: "A proxy must be selected to enable the global proxy",
});
}
});
export type SettingsFormInput = z.input<typeof settingsSchema>;
export type SettingsFormData = z.infer<typeof settingsSchema>;
+1
View File
@@ -79,6 +79,7 @@ export const statusPageSchema = z.object({
.object({
data: z.string(),
contentType: z.string(),
file: z.instanceof(File).optional(),
})
.nullable()
.optional()
+93 -1
View File
@@ -205,6 +205,7 @@
"infrastructure": "Infrastructure",
"notifications": "Notifications",
"tags": "Tags",
"proxies": "Proxies",
"checks": "Checks",
"incidents": "Incidents",
"statusPages": "Status pages",
@@ -244,6 +245,63 @@
}
},
"pages": {
"proxies": {
"header": {
"title": "Proxies",
"description": "Manage your proxy servers for routing HTTP monitoring traffic."
},
"fallback": {
"title": "No proxies yet",
"description": "Route HTTP monitor checks through a proxy server. Once you create a proxy, you can select it on individual monitors or set it as the global default in Settings.",
"actionButton": "Create a proxy"
},
"form": {
"name": {
"title": "Proxy name",
"description": "A friendly name to identify this proxy",
"optionName": "Proxy name",
"placeholder": "My proxy"
},
"server": {
"title": "Proxy server",
"description": "Where the proxy server is reachable. Set the protocol Checkmate uses to connect to it, its hostname or IP address, and the port it listens on.",
"option": {
"protocol": {
"label": "Protocol"
},
"host": {
"label": "Hostname / IP Address",
"placeholder": "proxy.example.com"
},
"port": {
"label": "Port"
}
}
},
"auth": {
"title": "Authentication",
"description": "Optional. Only needed if the proxy server requires basic authentication.",
"option": {
"username": {
"label": "Username",
"placeholder": "Proxy username",
"labelSet": "Username is set. Click Reset to change it."
},
"password": {
"label": "Password",
"labelSet": "Password is set. Click Reset to change it."
}
}
}
},
"table": {
"headers": {
"protocol": "Protocol",
"host": "Host",
"port": "Port"
}
}
},
"notFound": {
"title": "Oh no! You dropped your sushi!",
"subtitle": "Either the URL doesn’t exist, or you don’t have access to it."
@@ -581,6 +639,24 @@
"label": "URL",
"placeholder": "https://www.google.com"
},
"proxyMode": {
"label": "Proxy",
"value": {
"inheritDirect": "Default (direct connection)",
"inheritGlobal": "Default ({{name}})",
"none": "Never use a proxy",
"custom": "Custom proxy"
},
"description": {
"inheritDirect": "No global proxy is configured, connect directly. If a global proxy is later configured, this monitor will use it.",
"inheritGlobal": "Use the global proxy setting",
"none": "Always connects directly even if a global proxy is enabled later.",
"custom": "Route this monitor's checks through a specific proxy. Overrides global proxy"
}
},
"proxy": {
"label": "Proxy server"
},
"game": {
"label": "Choose game",
"placeholder": "Select a game"
@@ -624,7 +700,7 @@
},
"title": "General settings",
"description": {
"http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.",
"http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.",
"ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.",
"port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.",
"docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",
@@ -1286,6 +1362,22 @@
}
}
},
"globalProxy": {
"title": "Global proxy",
"description": "Route monitoring checks through a proxy by default. Applies to all HTTP monitors whose proxy setting is \"Default\"; monitors set to \"Never use a proxy\" or a custom proxy are unaffected.",
"option": {
"enabled": {
"label": "Use global proxy"
},
"proxy": {
"label": "Choose a global proxy"
},
"empty": {
"text": "No proxies configured yet.",
"link": "Create one first"
}
}
},
"pagespeed": {
"title": "Google PageSpeed API key",
"description": "Enter your Google PageSpeed API key to enable Google PageSpeed monitoring. Click Reset to update the key.",
+1034
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -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)." },
+75
View File
@@ -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,
},
});
+1717 -1382
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -49,6 +49,7 @@
"got": "14.4.7",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
"hpagent": "1.2.0",
"isomorphic-dompurify": "^2.26.0",
"jmespath": "^0.16.0",
"jsdom": "^26.1.0",
+14 -1
View File
@@ -7,10 +7,23 @@ import { parse as parseDomain } from "tldts";
type SSLCheckerType = typeof sslChecker;
type WhoisModule = typeof whoiser;
const DEFAULT_HTTPS_PORT = 443;
// Ports are user-defined, so a probe can dial something that accepts the
// connection and never completes the handshake. Bound the wait explicitly.
const CERTIFICATE_TIMEOUT_MS = 10_000;
export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: Monitor): Promise<SSLDetails> => {
const monitorUrl = new URL(monitor.url);
// A plain-HTTP monitor serves no certificate. Reject before dialing, so the
// caller gets this instead of a raw OpenSSL handshake error from the socket.
if (monitorUrl.protocol !== "https:") {
throw new AppError({ message: "Certificate is only available for HTTPS monitors", status: 400 });
}
const hostname = monitorUrl.hostname;
const cert = await checker(hostname);
// URL.port is "" for the scheme's default port, so fall back to 443; without
// this the certificate of a monitor on a non-standard port was probed on 443.
const port = monitorUrl.port === "" ? DEFAULT_HTTPS_PORT : Number(monitorUrl.port);
const cert = await checker(hostname, { port, timeout: CERTIFICATE_TIMEOUT_MS });
if (cert?.validTo === null || cert?.validTo === undefined) {
throw new Error("Certificate not found");
}
@@ -0,0 +1,89 @@
import { Request, Response, RequestHandler } from "express";
import { catchAsync } from "@/utils/catchAsync.js";
import { IProxiesService } from "@/domain/proxies/proxy.service.js";
import { requireTeamId } from "./controllerUtils.js";
import {
createProxyBodyValidation,
editProxyBodyValidation,
getProxyByIdParamValidation,
editProxyParamValidation,
deleteProxyParamValidation,
} from "@/api/validation/index.js";
export interface IProxiesController {
createProxy: RequestHandler;
getProxyById: RequestHandler;
getAllProxies: RequestHandler;
getProxiesByTeamId: RequestHandler;
editProxy: RequestHandler;
deleteProxy: RequestHandler;
}
class ProxyController implements IProxiesController {
constructor(private proxiesService: IProxiesService) {}
createProxy = catchAsync(async (req: Request, res: Response) => {
const validatedBody = createProxyBodyValidation.parse(req.body);
const teamId = requireTeamId(req.user?.teamId);
const proxy = await this.proxiesService.createProxy(validatedBody, teamId);
return res.status(200).json({
success: true,
msg: "Proxy created successfully",
data: proxy,
});
});
getProxyById = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId);
const { id: proxyId } = getProxyByIdParamValidation.parse(req.params);
const proxy = await this.proxiesService.getProxy(proxyId, teamId);
return res.status(200).json({
success: true,
msg: "Proxy retrieved successfully",
data: proxy,
});
});
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);
return res.status(200).json({
success: true,
msg: "Proxies retrieved successfully",
data: proxies,
});
});
editProxy = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId);
const { id: proxyId } = editProxyParamValidation.parse(req.params);
const validatedBody = editProxyBodyValidation.parse(req.body);
const updatedProxy = await this.proxiesService.updateProxy(proxyId, teamId, validatedBody);
return res.status(200).json({
success: true,
msg: "Proxy updated successfully",
data: updatedProxy,
});
});
deleteProxy = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId);
const { id: proxyId } = deleteProxyParamValidation.parse(req.params);
await this.proxiesService.deleteProxy(proxyId, teamId);
return res.status(200).json({
success: true,
msg: "Proxy deleted successfully",
});
});
}
export default ProxyController;
@@ -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",
@@ -7,7 +7,7 @@ const PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX = "/status/public";
// the status page on top of the global helmet policy, blocking external images,
// fonts, and stylesheets from custom CSS while keeping the app's Google Fonts.
const STATUS_PAGE_CSP = [
"img-src 'self' data:",
"img-src 'self' data: blob:",
"font-src 'self' data: https://fonts.gstatic.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
].join("; ");
+14
View File
@@ -0,0 +1,14 @@
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);
router.patch("/:id", proxyController.editProxy);
return router;
};
+1
View File
@@ -22,3 +22,4 @@ export * from "./notificationValidation.js";
export * from "./userValidation.js";
export * from "./incidentValidation.js";
export * from "./tagValidation.js";
export * from "./proxyValidation.js";
+25 -3
View File
@@ -10,6 +10,7 @@ import {
MonitorStatuses,
MonitorTypes,
PageSpeedStrategies,
ProxyModes,
} from "@/domain/monitors/monitor.type.js";
import { DateRanges, SortOrders } from "@/types/query.js";
@@ -100,6 +101,16 @@ const refineHeadMatching = (body: { method?: string; useAdvancedMatching?: boole
}
};
const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ctx: z.RefinementCtx) => {
if (body.proxyMode === "custom" && !body.proxyId) {
ctx.addIssue({
code: "custom",
path: ["proxyId"],
message: "A proxy must be selected when proxy mode is custom",
});
}
};
export const createMonitorBodyValidation = z
.object({
_id: z.string().optional(),
@@ -110,6 +121,8 @@ export const createMonitorBodyValidation = z
statusWindowThreshold: z.number().min(1).max(100).default(60),
url: z.string().min(1, "URL is required"),
ignoreTlsErrors: z.boolean().default(false),
proxyMode: z.enum(ProxyModes).default("inherit"),
proxyId: z.string().optional(),
useAdvancedMatching: z.boolean().default(false),
port: z.number().optional(),
isActive: z.boolean().optional(),
@@ -140,7 +153,8 @@ export const createMonitorBodyValidation = z
.superRefine(refineDnsHostname)
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern);
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection);
export const editMonitorBodyValidation = z
.object({
@@ -156,6 +170,8 @@ export const editMonitorBodyValidation = z
customUpCodes: z.array(httpStatusCode).optional(),
secret: z.string().optional(),
ignoreTlsErrors: z.boolean().optional(),
proxyMode: z.enum(ProxyModes).optional(),
proxyId: z.string().optional(),
useAdvancedMatching: z.boolean().optional(),
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
@@ -180,7 +196,8 @@ export const editMonitorBodyValidation = z
.superRefine(refineDnsHostname)
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern);
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection);
export const pauseMonitorParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
@@ -215,6 +232,8 @@ const importedMonitorSchema = z
statusWindowThreshold: z.number().min(1).max(100).default(60),
type: z.enum(MonitorTypes, "Invalid monitor type"),
ignoreTlsErrors: z.boolean().default(false),
proxyMode: z.enum(ProxyModes).default("inherit"),
proxyId: z.string().optional(),
useAdvancedMatching: z.boolean().default(false),
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
@@ -253,7 +272,8 @@ const importedMonitorSchema = z
.superRefine(refineDnsHostname)
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern);
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection);
export const importMonitorsBodyValidation = z.object({
monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"),
@@ -285,6 +305,8 @@ export const monitorResponseSchema = z
statusWindowSize: z.number(),
statusWindowThreshold: z.number(),
ignoreTlsErrors: z.boolean(),
proxyMode: z.enum(ProxyModes),
proxyId: z.string().optional(),
useAdvancedMatching: z.boolean(),
jsonPath: z.string().optional(),
expectedValue: z.string().optional(),
@@ -0,0 +1,45 @@
import { ProxyProtocols } from "@/domain/proxies/proxy.type.js";
import { z } from "zod";
//****************************************
// Proxy Validations
//****************************************
export const createProxyBodyValidation = z.object({
name: z.string().min(1, "Proxy name is required"),
protocol: z.enum(ProxyProtocols, "Invalid proxy protocol"),
host: z.string().min(1, "Proxy host is required"),
port: z.number().int().min(1, "Proxy port is required").max(65535, "Proxy port must be between 1 and 65535"),
username: z.string().optional(),
password: z.string().optional(),
});
export const editProxyBodyValidation = createProxyBodyValidation.extend({
clearPassword: z.boolean().optional(),
clearUsername: z.boolean().optional(),
});
export const getProxyByIdParamValidation = z.object({
id: z.string().min(1, "Proxy ID is required"),
});
export const editProxyParamValidation = z.object({
id: z.string().min(1, "Proxy ID is required"),
});
export const deleteProxyParamValidation = z.object({
id: z.string().min(1, "Proxy ID is required"),
});
export const proxyResponseSchema = z
.object({
id: z.string(),
teamId: z.string(),
name: z.string(),
protocol: z.enum(ProxyProtocols),
host: z.string(),
port: z.number(),
username: z.string().optional(),
hasPassword: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
})
.passthrough();
@@ -40,5 +40,16 @@ export const updateAppSettingsBodyValidation = z
temperature: z.number().min(1).max(150).optional(),
})
.optional(),
globalProxyEnabled: z.boolean().optional(),
globalProxyId: z.string().nullable().optional(),
})
.strip();
.strip()
.superRefine((body, ctx) => {
if (body.globalProxyEnabled === true && !body.globalProxyId) {
ctx.addIssue({
code: "custom",
path: ["globalProxyId"],
message: "A proxy must be selected to enable the global proxy",
});
}
});
+1 -1
View File
@@ -76,7 +76,7 @@ export const createApp = ({
directives: {
upgradeInsecureRequests: null,
"script-src": ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
"img-src": ["'self'", "data:", "https://img.shields.io"],
"img-src": ["'self'", "data:", "blob:", "https://img.shields.io"],
"object-src": ["'none'"],
"base-uri": ["'self'"],
},
+4 -1
View File
@@ -12,6 +12,7 @@ import NotificationController from "../api/controllers/notificationController.js
import TagsController from "../api/controllers/tagController.js";
import DiagnosticController from "../api/controllers/diagnosticController.js";
import IncidentController from "../api/controllers/incidentController.js";
import ProxyController from "@/api/controllers/proxyController.js";
import { ApiServices } from "@/config/services.api.js";
export interface InitializedControllers {
@@ -29,12 +30,13 @@ export interface InitializedControllers {
tagController: TagsController;
diagnosticController: DiagnosticController;
incidentController: IncidentController;
proxyController: ProxyController;
}
export const initializeControllers = (apiServices: ApiServices): InitializedControllers => {
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),
@@ -46,5 +48,6 @@ export const initializeControllers = (apiServices: ApiServices): InitializedCont
tagController: new TagsController(apiServices.tagsService),
diagnosticController: new DiagnosticController(apiServices.diagnosticService),
incidentController: new IncidentController(apiServices.incidentService),
proxyController: new ProxyController(apiServices.proxiesService),
};
};
+3
View File
@@ -19,6 +19,7 @@ import { createDiagnosticRoutes } from "@/api/routes/diagnosticRoutes.js";
import { createNotificationRoutes } from "@/api/routes/notificationRoutes.js";
import { createTagRoutes } from "@/api/routes/tagRoutes.js";
import { createIncidentRoutes } from "@/api/routes/incidentRoutes.js";
import { createProxyRoutes } from "@/api/routes/proxyRoutes.js";
export const setupRoutes = (app: Application, controllers: InitializedControllers, apiServices: ApiServices) => {
const verifyJWT = createVerifyJWT(apiServices.settingsService);
@@ -37,6 +38,7 @@ export const setupRoutes = (app: Application, controllers: InitializedController
const tagRoutes = createTagRoutes(controllers.tagController);
const diagnosticRoutes = createDiagnosticRoutes(controllers.diagnosticController, verifyJWT);
const incidentRoutes = createIncidentRoutes(controllers.incidentController);
const proxyRoutes = createProxyRoutes(controllers.proxyController);
app.use("/api/v1/auth", authApiLimiter, authRoutes);
app.use("/api/v1/monitors", verifyJWT, monitorRoutes);
@@ -52,4 +54,5 @@ export const setupRoutes = (app: Application, controllers: InitializedController
app.use("/api/v1/tags", verifyJWT, tagRoutes);
app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes);
app.use("/api/v1/incidents", verifyJWT, incidentRoutes);
app.use("/api/v1/proxies", verifyJWT, proxyRoutes);
};
+6 -3
View File
@@ -6,14 +6,14 @@ import { IStatusPageService, StatusPageService } from "@/domain/status-pages/sta
import { ITagsService, TagsService } from "@/domain/tags/tag.service.js";
import { IUserService, UserService } from "@/domain/users/user.service.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
import { ProxiesService, IProxiesService } from "@/domain/proxies/proxy.service.js";
import { SharedServices } from "@/config/services.shared.js";
// Third-party
import jwt from "jsonwebtoken";
import crypto from "crypto";
import { games } from "gamedig";
import { SharedServices } from "@/config/services.shared.js";
export interface ApiServices extends SharedServices {
worker: IJobScheduler; // control-plane handle only (DBQueueWorker in all-in-one, bare JobScheduler in API-only)
userService: IUserService;
@@ -23,6 +23,7 @@ export interface ApiServices extends SharedServices {
statusPageService: IStatusPageService;
tagsService: ITagsService;
diagnosticService: IDiagnosticService;
proxiesService: IProxiesService;
}
export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): ApiServices => {
@@ -45,6 +46,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
teamsRepository,
maintenanceWindowsRepository,
jobsRepository,
proxiesRepository,
} = shared;
const userService = new UserService({
@@ -94,7 +96,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
const statusPageService = new StatusPageService(statusPagesRepository, settingsService, monitorsRepository, checksRepository);
const tagsService = new TagsService(tagsRepository, monitorsRepository);
const diagnosticService = new DiagnosticService(db);
const proxiesService = new ProxiesService(proxiesRepository, monitorsRepository, settingsService);
return {
...shared,
worker: jobScheduler,
@@ -105,5 +107,6 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
statusPageService,
tagsService,
diagnosticService,
proxiesService,
};
};
+5
View File
@@ -52,6 +52,7 @@ import { IStatusPagesRepository } from "@/domain/status-pages/status-page-reposi
import { ITagsRepository } from "@/domain/tags/tag.repository.interface.js";
import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js";
import { IUsersRepository } from "@/domain/users/user.repository.interface.js";
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
// Mongo repository implementations
import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js";
@@ -70,6 +71,7 @@ import MongoStatusPagesRepository from "@/domain/status-pages/status-page-reposi
import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js";
import MongoTeamsRepository from "@/domain/teams/team.repository.model.js";
import MongoUsersRepository from "@/domain/users/user.repository.mongo.js";
import MongoProxiesRepository from "@/domain/proxies/proxy.repository.mongo.js";
// Shared infrastructure + business services that both the API and the worker process construct.
export interface SharedServices {
@@ -103,6 +105,7 @@ export interface SharedServices {
incidentsRepository: IIncidentsRepository;
teamsRepository: ITeamsRepository;
maintenanceWindowsRepository: IMaintenanceWindowsRepository;
proxiesRepository: IProxiesRepository;
}
export const buildShared = async ({
@@ -139,6 +142,7 @@ export const buildShared = async ({
const tagsRepository = new MongoTagsRepository();
const teamsRepository = new MongoTeamsRepository();
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
const proxiesRepository = new MongoProxiesRepository();
// Inject settings repository into settings service (now that DB is connected)
(settingsService as SettingsService).setRepository(settingsRepository);
@@ -221,6 +225,7 @@ export const buildShared = async ({
incidentsRepository,
teamsRepository,
maintenanceWindowsRepository,
proxiesRepository,
};
return sharedServices;
};
+12 -2
View File
@@ -26,7 +26,7 @@ import { NotificationReactor } from "@/worker/reactors/reactor.notification.js";
import { IncidentReactor } from "@/worker/reactors/reactor.incident.js";
import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
import { DBQueueWorker } from "@/worker/worker.db-queue.js";
import { ProxyResolver } from "@/service/network/ProxyResolver.js";
// Network providers
import { PingProvider } from "@/service/network/PingProvider.js";
import { HttpProvider } from "@/service/network/HttpProvider.js";
@@ -64,6 +64,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
incidentsRepository,
teamsRepository,
maintenanceWindowsRepository,
proxiesRepository,
} = shared;
// ***********************
@@ -93,6 +94,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
dnsProvider,
]);
const proxyResolver = new ProxyResolver(proxiesRepository, settingsService, logger);
const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService, jobsRepository);
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository);
const monitorStatusPolicy = new MonitorStatusPolicy();
@@ -110,7 +112,15 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
// Check producer/evaluator
// Handles creating and evaluatiog checks
// ***********************
const checkProducer = new CheckProducer(monitorsRepository, maintenanceWindowsRepository, checkService, networkService, bufferService, logger);
const checkProducer = new CheckProducer(
monitorsRepository,
maintenanceWindowsRepository,
checkService,
networkService,
proxyResolver,
bufferService,
logger
);
const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy);
const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepository, geoChecksService, bufferService, logger);
@@ -0,0 +1,27 @@
import { MonitorModel } from "../../domain/monitors/monitor.model.js";
import type { ILogger } from "@/utils/logger.js";
/**
* Backfill proxyMode on monitors created before the field existed.
*
* Mongoose defaults only apply to new documents, and monitorResponseSchema
* requires proxyMode on every monitor read.
*/
export async function backfillMonitorProxyMode(logger: ILogger): Promise<void> {
const SERVICE_NAME = "Migration:BackfillMonitorProxyMode";
try {
logger.info({ service: SERVICE_NAME, message: "Starting proxyMode backfill" });
const result = await MonitorModel.updateMany({ proxyMode: { $exists: false } }, { $set: { proxyMode: "inherit" } });
logger.info({
service: SERVICE_NAME,
message: `Backfilled proxyMode on ${result.modifiedCount} monitors`,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ service: SERVICE_NAME, message: `Error backfilling proxyMode: ${errorMessage}` });
throw error;
}
}
+2
View File
@@ -9,6 +9,7 @@ import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintena
import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js";
import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js";
import { slimRecentChecks } from "./0010_slimRecentChecks.js";
import { backfillMonitorProxyMode } from "./0011_backfillMonitorProxyMode.js";
import type { ILogger } from "@/utils/logger.js";
type MigrationEntry = {
@@ -27,6 +28,7 @@ const migrations: MigrationEntry[] = [
{ name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt },
{ name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings },
{ name: "0010_slimRecentChecks", execute: slimRecentChecks },
{ name: "0011_backfillMonitorProxyMode", execute: backfillMonitorProxyMode },
];
const runMigrations = async (logger: ILogger) => {
@@ -40,6 +40,8 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
singleton: { type: Boolean, required: true, unique: true, default: true },
version: { type: Number, default: 1 },
globalThresholds: { type: thresholdsSchema },
globalProxyEnabled: { type: Boolean, default: false },
globalProxyId: { type: String, default: null },
},
{ timestamps: true }
);
@@ -28,6 +28,8 @@ class MongoSettingsRepository implements ISettingsRepository {
singleton: doc.singleton,
version: doc.version ?? 1,
globalThresholds: doc.globalThresholds ?? undefined,
globalProxyEnabled: doc.globalProxyEnabled ?? false,
globalProxyId: doc.globalProxyId ?? undefined,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -49,6 +49,8 @@ export interface Settings {
singleton: boolean;
version: number;
globalThresholds?: SettingsThresholds;
globalProxyEnabled: boolean;
globalProxyId?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -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 };
@@ -543,35 +546,33 @@ class MongoChecksRepository implements IChecksRepository {
totalChecks: upChecksDoc?.totalChecks ?? 0,
};
const checks = (hardwareMetrics ?? []).map(
(metric): HardwareCheckStats => ({
bucketDate: metric._id,
avgCpuUsage: metric.avgCpuUsage ?? 0,
avgMemoryUsage: metric.avgMemoryUsage ?? 0,
avgTemperature: metric.avgTemperature ?? [],
disks: (metric.disks ?? []).map((disk) => ({
name: disk?.name ?? "",
readSpeed: disk?.readSpeed ?? 0,
writeSpeed: disk?.writeSpeed ?? 0,
totalBytes: disk?.totalBytes ?? 0,
freeBytes: disk?.freeBytes ?? 0,
usagePercent: disk?.usagePercent ?? 0,
})),
net: (metric.net ?? []).map((iface) => ({
name: iface?.name ?? "",
bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0,
deltaBytesRecv: iface?.deltaBytesRecv ?? 0,
deltaPacketsSent: iface?.deltaPacketsSent ?? 0,
deltaPacketsRecv: iface?.deltaPacketsRecv ?? 0,
deltaErrIn: iface?.deltaErrIn ?? 0,
deltaErrOut: iface?.deltaErrOut ?? 0,
deltaDropIn: iface?.deltaDropIn ?? 0,
deltaDropOut: iface?.deltaDropOut ?? 0,
deltaFifoIn: iface?.deltaFifoIn ?? 0,
deltaFifoOut: iface?.deltaFifoOut ?? 0,
})),
})
);
const checks = (hardwareMetrics ?? []).map((metric): HardwareCheckStats => ({
bucketDate: metric._id,
avgCpuUsage: metric.avgCpuUsage ?? 0,
avgMemoryUsage: metric.avgMemoryUsage ?? 0,
avgTemperature: metric.avgTemperature ?? [],
disks: (metric.disks ?? []).map((disk) => ({
name: disk?.name ?? "",
readSpeed: disk?.readSpeed ?? 0,
writeSpeed: disk?.writeSpeed ?? 0,
totalBytes: disk?.totalBytes ?? 0,
freeBytes: disk?.freeBytes ?? 0,
usagePercent: disk?.usagePercent ?? 0,
})),
net: (metric.net ?? []).map((iface) => ({
name: iface?.name ?? "",
bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0,
deltaBytesRecv: iface?.deltaBytesRecv ?? 0,
deltaPacketsSent: iface?.deltaPacketsSent ?? 0,
deltaPacketsRecv: iface?.deltaPacketsRecv ?? 0,
deltaErrIn: iface?.deltaErrIn ?? 0,
deltaErrOut: iface?.deltaErrOut ?? 0,
deltaDropIn: iface?.deltaDropIn ?? 0,
deltaDropOut: iface?.deltaDropOut ?? 0,
deltaFifoIn: iface?.deltaFifoIn ?? 0,
deltaFifoOut: iface?.deltaFifoOut ?? 0,
})),
}));
return {
monitorType: "hardware" as const,
+13 -2
View File
@@ -1,6 +1,6 @@
import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.type.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.type.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods, ProxyModes } from "@/domain/monitors/monitor.type.js";
import type {
CheckAudits,
ILighthouseAudit,
@@ -14,7 +14,7 @@ type CheckSnapshotDocument = Omit<CheckSnapshot, "createdAt"> & { createdAt: Dat
type MonitorDocumentBase = Omit<
Monitor,
"id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt"
"id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "proxyId" | "createdAt" | "updatedAt"
> & {
statusWindow: boolean[];
recentChecks: CheckSnapshotDocument[];
@@ -22,6 +22,7 @@ type MonitorDocumentBase = Omit<
tags: Types.ObjectId[];
selectedDisks: string[];
matchMethod?: MonitorMatchMethod;
proxyId?: Types.ObjectId;
};
interface MonitorDocument extends MonitorDocumentBase {
@@ -170,6 +171,16 @@ const MonitorSchema = new Schema<MonitorDocument>(
type: Boolean,
default: false,
},
proxyMode: {
type: String,
enum: ProxyModes,
default: "inherit",
},
proxyId: {
type: Schema.Types.ObjectId,
ref: "Proxy",
},
useAdvancedMatching: {
type: Boolean,
default: false,
@@ -53,6 +53,7 @@ export interface IMonitorsRepository {
// counts
findMonitorCountByTeamIdAndType(teamId: string, config: TeamQueryConfig): Promise<number>;
findMonitorCountByProxyId(proxyId: string): Promise<number>;
// other
findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise<MonitorsSummary>;
@@ -172,6 +172,11 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return count;
};
findMonitorCountByProxyId = async (proxyId: string): Promise<number> => {
const count = await MonitorModel.countDocuments({ proxyId });
return count;
};
updateById = async (monitorId: string, teamId: string, patch: Partial<Monitor>) => {
const updatedMonitor = await MonitorModel.findOneAndUpdate(
{ _id: monitorId, teamId },
@@ -418,6 +423,8 @@ class MongoMonitorsRepository implements IMonitorsRepository {
statusWindowThreshold: doc.statusWindowThreshold,
type: doc.type,
ignoreTlsErrors: doc.ignoreTlsErrors,
proxyMode: doc.proxyMode ?? "inherit",
proxyId: doc.proxyId ? toStringId(doc.proxyId) : undefined,
useAdvancedMatching: doc.useAdvancedMatching ?? false,
jsonPath: doc.jsonPath ?? undefined,
expectedValue: doc.expectedValue ?? undefined,
@@ -41,6 +41,9 @@ export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type);
export const ProxyModes = ["inherit", "none", "custom"] as const;
export type ProxyMode = (typeof ProxyModes)[number];
export const UptimeDetailsSupportedTypes = [
"http",
"ping",
@@ -81,6 +84,8 @@ export interface Monitor {
statusWindowThreshold: number;
type: MonitorType;
ignoreTlsErrors: boolean;
proxyMode: ProxyMode;
proxyId?: string;
useAdvancedMatching: boolean;
jsonPath?: string;
expectedValue?: string;
+38
View File
@@ -0,0 +1,38 @@
import { Schema, model, type Types } from "mongoose";
import type { Proxy as ProxyEntity } from "@/domain/proxies/proxy.type.js";
import { ProxyProtocols } from "@/domain/proxies/proxy.type.js";
type ProxyDocumentBase = Omit<ProxyEntity, "id" | "teamId" | "createdAt" | "updatedAt">;
interface ProxyDocument extends ProxyDocumentBase {
_id: Types.ObjectId;
teamId: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
}
const ProxySchema = new Schema<ProxyDocument>(
{
teamId: {
type: Schema.Types.ObjectId,
ref: "Team",
immutable: true,
required: true,
},
name: { type: String, required: true },
protocol: { type: String, enum: ProxyProtocols, required: true },
host: { type: String, required: true },
port: { type: Number, required: true },
username: { type: String, required: false },
password: { type: String, required: false },
},
{ timestamps: true }
);
ProxySchema.index({ teamId: 1, name: 1 }, { unique: true });
const ProxyModel = model<ProxyDocument>("Proxy", ProxySchema);
export type { ProxyDocument };
export { ProxyModel };
export default ProxyModel;
@@ -0,0 +1,15 @@
import { Proxy } from "@/domain/proxies/proxy.type.js";
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[]>;
// update
updateById(proxyId: string, teamId: string, patch: Partial<Proxy>, options?: { unsetPassword?: boolean; unsetUsername?: boolean }): Promise<Proxy>;
// delete
deleteById(proxyId: string, teamId: string): Promise<Proxy>;
}
@@ -0,0 +1,112 @@
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
import { Proxy } from "@/domain/proxies/proxy.type.js";
import { ProxyDocument, ProxyModel } from "@/domain/proxies/proxy.model.js";
import { AppError } from "@/utils/AppError.js";
import { toStringId, toDateString } from "@/utils/mongoMappers.js";
import { Error as MongooseError, UpdateQuery } from "mongoose";
const SERVICE_NAME = "ProxiesRepository";
class MongoProxiesRepository implements IProxiesRepository {
static SERVICE_NAME = SERVICE_NAME;
private toEntity = (doc: ProxyDocument): Proxy => {
return {
id: toStringId(doc._id),
teamId: toStringId(doc.teamId),
name: doc.name,
protocol: doc.protocol,
host: doc.host,
port: doc.port,
username: doc.username,
password: doc.password,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
};
async create(proxyData: Partial<Proxy>): Promise<Proxy> {
try {
const proxy = await ProxyModel.create({ ...proxyData });
return this.toEntity(proxy);
} catch (error) {
if (error && typeof error === "object" && (error as { code?: number }).code === 11000) {
throw new AppError({ message: `A proxy named "${proxyData.name}" already exists`, service: SERVICE_NAME, status: 409 });
}
throw error;
}
}
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) {
throw new AppError({ message: "Proxy not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(proxy);
}
async findByIdOrNull(proxyId: string, teamId?: string): Promise<Proxy | null> {
try {
const proxy = await ProxyModel.findOne(teamId ? { _id: proxyId, teamId } : { _id: proxyId });
return proxy ? this.toEntity(proxy) : null;
} catch (error) {
if (error instanceof MongooseError.CastError) {
return null;
}
throw error;
}
}
async findByTeamId(teamId: string): Promise<Proxy[]> {
const proxies = await ProxyModel.find({ teamId });
return proxies.map(this.toEntity);
}
async updateById(
proxyId: string,
teamId: string,
patch: Partial<Proxy>,
options?: { unsetPassword?: boolean; unsetUsername?: boolean }
): Promise<Proxy> {
try {
const update: UpdateQuery<ProxyDocument> = {
$set: {
...patch,
},
};
if (options?.unsetPassword) {
delete update.$set?.password;
update.$unset = { ...update.$unset, password: 1 };
}
if (options?.unsetUsername) {
delete update.$set?.username;
update.$unset = { ...update.$unset, username: 1 };
}
const updatedProxy = await ProxyModel.findOneAndUpdate({ _id: proxyId, teamId }, update, { new: true, runValidators: true });
if (!updatedProxy) {
throw new AppError({ message: "Proxy not found or could not be updated", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(updatedProxy);
} catch (error) {
if (error && typeof error === "object" && (error as { code?: number }).code === 11000) {
throw new AppError({ message: `A proxy named "${patch.name}" already exists`, service: SERVICE_NAME, status: 409 });
}
throw error;
}
}
async deleteById(proxyId: string, teamId: string): Promise<Proxy> {
const deletedProxy = await ProxyModel.findOneAndDelete({ _id: proxyId, teamId });
if (!deletedProxy) {
throw new AppError({ message: "Proxy not found or could not be deleted", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(deletedProxy);
}
}
export default MongoProxiesRepository;
+111
View File
@@ -0,0 +1,111 @@
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";
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>;
}
const SERVICE_NAME = "ProxiesService";
export class ProxiesService implements IProxiesService {
constructor(
private proxiesRepository: IProxiesRepository,
private monitorsRepository: IMonitorsRepository,
private settingsService: ISettingsService
) {}
private toResponse = ({ password, ...rest }: Proxy): ProxyResponse => ({ ...rest, hasPassword: Boolean(password) });
createProxy = async (proxyData: Partial<Proxy>, teamId: string): Promise<ProxyResponse> => {
proxyData.teamId = teamId;
if (!proxyData.password) {
delete proxyData.password;
}
if (!proxyData.username) {
delete proxyData.username;
}
const raw = await this.proxiesRepository.create(proxyData);
return this.toResponse(raw);
};
getProxy = async (proxyId: string, teamId: string): Promise<ProxyResponse> => {
const raw = await this.proxiesRepository.findById(proxyId, teamId);
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) => {
return this.toResponse(r);
});
return clean;
};
updateProxy = async (
proxyId: string,
teamId: string,
patch: Partial<Proxy> & { clearPassword?: boolean; clearUsername?: boolean }
): Promise<ProxyResponse> => {
// Empty/absent password and username don't delete, clearPassword / clearUsername does
const { clearPassword, clearUsername, ...proxyPatch } = patch;
if (!proxyPatch.password) {
delete proxyPatch.password;
}
if (!proxyPatch.username) {
delete proxyPatch.username;
}
const raw = await this.proxiesRepository.updateById(proxyId, teamId, proxyPatch, {
unsetPassword: clearPassword === true,
unsetUsername: clearUsername === true,
});
return this.toResponse(raw);
};
deleteProxy = async (proxyId: string, teamId: string): Promise<ProxyResponse> => {
// Make sure proxy is not in use
const monitorsUsingProxy = await this.monitorsRepository.findMonitorCountByProxyId(proxyId);
if (monitorsUsingProxy > 0) {
throw new AppError({
message: `Proxy still in use by ${monitorsUsingProxy} monitor${monitorsUsingProxy > 1 ? "s" : ""}`,
service: SERVICE_NAME,
status: 409,
});
}
// Make sure this is not set as a global proxy
const settings = await this.settingsService.getDBSettings();
if (settings.globalProxyId === proxyId) {
throw new AppError({
message: "Proxy is set as the global proxy in settings",
service: SERVICE_NAME,
status: 409,
});
}
const raw = await this.proxiesRepository.deleteById(proxyId, teamId);
return this.toResponse(raw);
};
}
+19
View File
@@ -0,0 +1,19 @@
export const ProxyProtocols = ["http", "https"] as const;
export type ProxyProtocol = (typeof ProxyProtocols)[number];
export interface Proxy {
id: string;
teamId: string;
name: string;
protocol: ProxyProtocol;
host: string;
port: number;
username?: string;
password?: string;
createdAt: string;
updatedAt: string;
}
export type ProxyResponse = Omit<Proxy, "password"> & { hasPassword: boolean };
export type ProxySummary = Pick<Proxy, "id" | "name" | "host" | "port">;
+56 -8
View File
@@ -9,11 +9,17 @@ import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { isStatusUp } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import CacheableLookup from "cacheable-lookup";
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
import { CheckContext } from "@/types/network.js";
export const PROXY_CONNECT_ERROR_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "ENOTFOUND", "EAI_AGAIN", "EPIPE"]);
export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
readonly type = "http";
// Shared, pooled agents reused across every check
private static readonly PROXY_AGENT_CACHE_MAX = 50;
private readonly proxyAgents = new Map<string, { http: HttpProxyAgent; https: HttpsProxyAgent }>();
private readonly httpAgent: HttpAgent;
private readonly httpsAgent: HttpsAgent;
private readonly httpsAgentInsecure: HttpsAgent;
@@ -41,6 +47,44 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
return type === "http";
}
private proxyFailureMessage = (error: RequestError, proxyUrl: string): string | undefined => {
if (error.response || !error.code || !PROXY_CONNECT_ERROR_CODES.has(error.code)) {
return undefined;
}
const { hostname, port } = new URL(proxyUrl);
return `Proxy connection failed (${hostname}:${port}): ${error.message}`;
};
private getProxyAgents(proxyUrl: string, ignoreTlsErrors: boolean) {
const key = `${proxyUrl}|${ignoreTlsErrors}`;
const hit = this.proxyAgents.get(key);
// Agent already exists, refresh
if (hit) {
this.proxyAgents.delete(key); // Refresh
this.proxyAgents.set(key, hit);
return hit;
}
// Create new agents
const agentOptions = { keepAlive: true, maxSockets: 256, maxFreeSockets: 256, proxy: proxyUrl };
const pair = {
http: new HttpProxyAgent(agentOptions),
https: new HttpsProxyAgent({ ...agentOptions, rejectUnauthorized: !ignoreTlsErrors }),
};
// Cache agents
this.proxyAgents.set(key, pair);
if (this.proxyAgents.size > HttpProvider.PROXY_AGENT_CACHE_MAX) {
const [oldestKey] = this.proxyAgents.keys(); // Destructuring gets the first inserted, ie oldest, item
if (oldestKey === undefined) return pair;
const oldest = this.proxyAgents.get(oldestKey);
oldest?.http.destroy();
oldest?.https.destroy();
this.proxyAgents.delete(oldestKey);
}
return pair;
}
private buildResponse<T>(
monitor: Monitor,
opts: {
@@ -113,20 +157,22 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
};
}
private handleHttpError<T>(error: unknown, monitor: Monitor): MonitorStatusResponse<T> {
private handleHttpError<T>(error: unknown, monitor: Monitor, ctx?: CheckContext): MonitorStatusResponse<T> {
if (error instanceof HTTPError || error instanceof RequestError) {
const statusCode = error.response?.statusCode;
const statusUp = isStatusUp(statusCode, monitor.customUpCodes);
const responseTime = error.timings?.phases?.total ?? 0;
if (!statusUp) {
const message = (ctx?.proxyUrl && error instanceof RequestError && this.proxyFailureMessage(error, ctx.proxyUrl)) || error.message;
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: statusCode ?? NETWORK_ERROR,
message: error.message,
message: message,
responseTime,
timings: error.timings,
payload: null as T,
@@ -156,7 +202,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
};
}
async handle<T>(monitor: Monitor): Promise<MonitorStatusResponse<T>> {
async handle<T>(monitor: Monitor, ctx?: CheckContext): Promise<MonitorStatusResponse<T>> {
const { url, secret, ignoreTlsErrors } = monitor;
if (!url) {
@@ -167,10 +213,12 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined,
};
options.agent = {
http: this.httpAgent,
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
};
options.agent = ctx?.proxyUrl
? this.getProxyAgents(ctx.proxyUrl, Boolean(ignoreTlsErrors))
: {
http: this.httpAgent,
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
};
options.method = monitor.method;
@@ -188,7 +236,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
timings: response.timings,
});
} catch (error: unknown) {
return this.handleHttpError(error, monitor);
return this.handleHttpError(error, monitor, ctx);
}
}
}
@@ -1,8 +1,8 @@
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { MonitorStatusResponse } from "@/types/network.js";
import { CheckContext } from "@/types/network.js";
export interface IStatusProvider<T> {
type: string;
supports: (type: MonitorType) => boolean;
handle(monitor: Monitor): Promise<MonitorStatusResponse<T>>;
handle(monitor: Monitor, ctx?: CheckContext): Promise<MonitorStatusResponse<T>>;
}
+111
View File
@@ -0,0 +1,111 @@
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { Monitor } from "@/domain/monitors/monitor.type.js";
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
import { ILogger } from "@/utils/logger.js";
import { Proxy } from "@/domain/proxies/proxy.type.js";
export interface IProxyResolver {
resolve(monitor: Monitor): Promise<string | undefined>;
}
const SERVICE_NAME = "ProxyResolver";
export const buildProxyUrl = (proxy: Proxy): string => {
const { protocol, host, port, username, password } = proxy;
let credentials = "";
if (username) {
credentials = password ? `${encodeURIComponent(username)}:${encodeURIComponent(password)}@` : `${encodeURIComponent(username)}@`;
}
return `${protocol}://${credentials}${host}:${port}`;
};
export class ProxyResolver implements IProxyResolver {
static SERVICE_NAME = SERVICE_NAME;
constructor(
private proxiesRepository: IProxiesRepository,
private settingsService: ISettingsService,
private logger: ILogger,
private ttlMs = 60_000
) {}
private cache = new Map<string, { value: Promise<unknown>; expiresAt: number }>();
private getCached<T>(key: string, fetch: () => Promise<T>): Promise<T> {
const hit = this.cache.get(key);
if (hit && hit.expiresAt > Date.now()) {
return hit.value as Promise<T>;
}
// Cache the in flight promise so concurrent misses share one fetch
const value = fetch();
this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs });
// Don't negative cache rejections, evict so the next resolve retries
value.catch(() => this.cache.delete(key));
return value;
}
private warn(message: string, monitor: Monitor, error?: unknown) {
this.logger.warn({
message,
service: SERVICE_NAME,
method: "resolve",
details: {
monitorId: monitor.id,
proxyMode: monitor.proxyMode,
proxyId: monitor.proxyId,
...(error instanceof Error ? { error: error.message } : {}),
},
});
}
private resolveCustom = async (monitor: Monitor): Promise<string | undefined> => {
const { proxyId, teamId } = monitor;
if (!proxyId) {
this.warn("Proxy mode is custom, but proxyId is missing, connecting direct", monitor);
return undefined;
}
const proxy = await this.getCached(`proxy:${proxyId}:${teamId}`, () => this.proxiesRepository.findByIdOrNull(proxyId, teamId));
if (!proxy) {
this.warn("Proxy not found, connecting direct", monitor);
return undefined;
}
return buildProxyUrl(proxy);
};
private resolveInherit = async (monitor: Monitor): Promise<string | undefined> => {
const settings = await this.getCached("settings", () => this.settingsService.getDBSettings());
if (!settings.globalProxyEnabled) {
return undefined;
}
const globalProxyId = settings.globalProxyId;
if (!globalProxyId) {
return undefined;
}
const proxy = await this.getCached(`proxy:${globalProxyId}`, () => this.proxiesRepository.findByIdOrNull(globalProxyId));
if (!proxy) {
this.warn("Global proxy references a proxy that no longer exists, connecting direct", monitor);
return undefined;
}
return buildProxyUrl(proxy);
};
async resolve(monitor: Monitor): Promise<string | undefined> {
if (monitor.type !== "http") {
return undefined;
}
try {
switch (monitor.proxyMode) {
case "none":
return undefined;
case "custom":
return await this.resolveCustom(monitor);
default:
// "inherit" is the default mode
return await this.resolveInherit(monitor);
}
} catch (error: unknown) {
this.warn("Proxy resolution failed, connecting direct", monitor, error);
return undefined;
}
}
}
+7 -4
View File
@@ -1,5 +1,5 @@
import type { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js";
import type { CheckContext, MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js";
import type { AxiosStatic } from "axios";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR } from "@/types/network.js";
@@ -8,7 +8,7 @@ import { IStatusProvider } from "./network/IStatusProvider.js";
const SERVICE_NAME = "NetworkService";
export interface INetworkService {
requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }, ctx?: CheckContext): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
requestWebhook(
type: string,
url: string,
@@ -39,12 +39,15 @@ export class NetworkService implements INetworkService {
}
// Main entry point
async requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
async requestStatus<T extends MonitorType>(
monitor: Monitor & { type: T },
ctx?: CheckContext
): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
const provider = this.providers.find((p) => p.supports(monitor.type));
if (!provider) {
return this.handleUnsupportedType(monitor.type) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
}
return provider.handle(monitor) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
return provider.handle(monitor, ctx) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
}
private async handleUnsupportedType(type: string): Promise<MonitorStatusResponse> {
+4
View File
@@ -152,3 +152,7 @@ export type StatusChangeResult = {
};
export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusResponse<T>, "monitorId" | "teamId" | "type">>;
export type CheckContext = {
proxyUrl?: string;
};
+3
View File
@@ -12,6 +12,9 @@ declare module "ssl-checker" {
export interface SSLOptions {
method?: "GET" | "HEAD";
port?: number;
// Inherited from https.RequestOptions; ssl-checker rejects with "Timed Out"
// when the socket exceeds it.
timeout?: number;
agent?: Agent;
rejectUnauthorized?: boolean;
validateSubjectAltName?: boolean;
+4 -1
View File
@@ -9,6 +9,7 @@ import { ICheckService } from "@/domain/checks/check.service.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
import { isWindowActive } from "@/utils/maintenanceWindow.js";
import { IProxyResolver } from "@/service/network/ProxyResolver.js";
export interface ICheckProducer {
produce(monitor: Monitor): Promise<{ status: MonitorStatusResponse; check: Check } | null>;
@@ -22,6 +23,7 @@ export class CheckProducer implements ICheckProducer {
private maintenanceWindowsRepository: IMaintenanceWindowsRepository,
private checkService: ICheckService,
private networkService: INetworkService,
private proxyResolver: IProxyResolver,
private bufferService: IBufferService,
private logger: ILogger
) {}
@@ -56,7 +58,8 @@ export class CheckProducer implements ICheckProducer {
}
// Step 1b: Acquire status
const status = await this.networkService.requestStatus(monitor);
const proxyUrl = await this.proxyResolver.resolve(monitor);
const status = await this.networkService.requestStatus(monitor, { proxyUrl });
if (!status) {
throw new Error("No network response");
}
@@ -127,12 +127,14 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
};
const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) };
const proxyResolver = { resolve: jest.fn().mockResolvedValue(undefined) };
const checkProducer = new CheckProducer(
monitorsRepo as any,
maintenanceWindowsRepo as any,
checkService as any,
networkService as any,
proxyResolver as any,
bufferStub as any,
logger
);
@@ -1,4 +1,5 @@
import { describe, expect, it, jest } from "@jest/globals";
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
@@ -494,4 +495,152 @@ describe("HttpProvider", () => {
expect(advancedMatcher.validate).not.toHaveBeenCalled();
});
});
// ── Proxy support ────────────────────────────────────────────────────
describe("proxy support", () => {
const PROXY_URL = "http://proxy.example.com:8080";
const gotOptions = (callIndex = 0) => mockGot.mock.calls[callIndex]?.[1] as any;
const makeConnectionError = (code: string, message = `connect ${code} 10.0.0.1:8080`) => {
const err = new RequestError(message);
(err as any).code = code;
return err;
};
it("uses hpagent proxy agents when ctx.proxyUrl is set", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
expect(gotOptions().agent.http).toBeInstanceOf(HttpProxyAgent);
expect(gotOptions().agent.https).toBeInstanceOf(HttpsProxyAgent);
});
it("uses the shared agents when ctx has no proxyUrl", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor());
await provider.handle(makeMonitor(), {});
expect(gotOptions(0).agent.http).not.toBeInstanceOf(HttpProxyAgent);
expect(gotOptions(1).agent.http).toBe(gotOptions(0).agent.http);
expect(gotOptions(1).agent.https).toBe(gotOptions(0).agent.https);
});
it("reuses the cached agent pair across checks with the same proxyUrl", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
expect(gotOptions(1).agent.http).toBe(gotOptions(0).agent.http);
expect(gotOptions(1).agent.https).toBe(gotOptions(0).agent.https);
});
it("builds distinct agent pairs for distinct proxyUrls", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
await provider.handle(makeMonitor(), { proxyUrl: "http://other.example.com:3128" });
expect(gotOptions(1).agent.http).not.toBe(gotOptions(0).agent.http);
});
it("builds distinct pairs per ignoreTlsErrors and sets rejectUnauthorized accordingly", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor({ ignoreTlsErrors: false }), { proxyUrl: PROXY_URL });
await provider.handle(makeMonitor({ ignoreTlsErrors: true }), { proxyUrl: PROXY_URL });
expect(gotOptions(1).agent.https).not.toBe(gotOptions(0).agent.https);
expect(gotOptions(0).agent.https.options.rejectUnauthorized).toBe(true);
expect(gotOptions(1).agent.https.options.rejectUnauthorized).toBe(false);
});
it("evicts and destroys the least-recently-used pair past the cache cap", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor(), { proxyUrl: "http://proxy0.example.com:8080" });
const first = gotOptions(0).agent;
const httpDestroy = jest.spyOn(first.http, "destroy");
const httpsDestroy = jest.spyOn(first.https, "destroy");
for (let i = 1; i <= 49; i++) {
await provider.handle(makeMonitor(), { proxyUrl: `http://proxy${i}.example.com:8080` });
}
expect(httpDestroy).not.toHaveBeenCalled();
await provider.handle(makeMonitor(), { proxyUrl: "http://proxy50.example.com:8080" });
expect(httpDestroy).toHaveBeenCalled();
expect(httpsDestroy).toHaveBeenCalled();
// The evicted pair is gone — the same proxyUrl now gets a fresh pair
await provider.handle(makeMonitor(), { proxyUrl: "http://proxy0.example.com:8080" });
expect(gotOptions(51).agent.http).not.toBe(first.http);
});
it("names the proxy in the message when the proxy connection fails", async () => {
mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED"));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
expect(result.status).toBe(false);
expect(result.code).toBe(NETWORK_ERROR);
expect(result.message).toBe("Proxy connection failed (proxy.example.com:8080): connect ECONNREFUSED 10.0.0.1:8080");
});
it("does not attribute timeouts to the proxy", async () => {
mockGot.mockRejectedValue(makeConnectionError("ETIMEDOUT", "Timeout awaiting 'request' for 30000ms"));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
expect(result.status).toBe(false);
expect(result.message).toBe("Timeout awaiting 'request' for 30000ms");
});
it("leaves connection-error messages unprefixed for direct checks", async () => {
mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED"));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("connect ECONNREFUSED 10.0.0.1:8080");
});
it("never leaks proxy credentials into the message", async () => {
mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED"));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor(), { proxyUrl: "http://bob:sekret@proxy.example.com:8080" });
expect(result.message).toContain("proxy.example.com:8080");
expect(result.message).not.toContain("bob");
expect(result.message).not.toContain("sekret");
});
it("does not prefix errors that carry a response (target failure through a healthy proxy)", async () => {
const err = new RequestError("Response code 500 (Internal Server Error)");
(err as any).code = "ECONNRESET";
(err as any).response = { statusCode: 500, body: "", headers: {} };
(err as any).timings = { phases: { total: 25 } };
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
expect(result.status).toBe(false);
expect(result.code).toBe(500);
expect(result.message).toBe("Response code 500 (Internal Server Error)");
});
});
});
@@ -26,6 +26,7 @@ const createProducer = (overrides?: Record<string, any>) => {
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) },
checkService: { toCheck: jest.fn().mockReturnValue({ id: "check-1" }) },
networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) },
proxyResolver: { resolve: jest.fn().mockResolvedValue(undefined) },
buffer: { addToBuffer: jest.fn() },
...overrides,
};
@@ -34,6 +35,7 @@ const createProducer = (overrides?: Record<string, any>) => {
defaults.maintenanceWindowsRepository as any,
defaults.checkService as any,
defaults.networkService as any,
defaults.proxyResolver as any,
defaults.buffer as any,
defaults.logger as any
);
@@ -82,6 +84,29 @@ describe("CheckProducer", () => {
expect(defaults.networkService.requestStatus).toHaveBeenCalled();
});
// ── proxy resolution ──────────────────────────────────────────────────────
it("passes the resolved proxy url into requestStatus", async () => {
const { producer, defaults } = createProducer({
proxyResolver: { resolve: jest.fn().mockResolvedValue("http://proxy.example.com:8080") },
});
const monitor = makeMonitor();
await producer.produce(monitor);
expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: "http://proxy.example.com:8080" });
});
it("still produces a check when the resolver returns undefined", async () => {
const { producer, defaults } = createProducer();
const monitor = makeMonitor();
const result = await producer.produce(monitor);
expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined });
expect(result).toEqual({ status: expect.objectContaining({ monitorId: "m1" }), check: { id: "check-1" } });
});
// ── acquire / record ──────────────────────────────────────────────────────
it("throws when the network response is null", async () => {
@@ -54,7 +54,7 @@ describe("NetworkService", () => {
const result = await service.requestStatus(monitor);
expect(provider.supports).toHaveBeenCalledWith("http");
expect(provider.handle).toHaveBeenCalledWith(monitor);
expect(provider.handle).toHaveBeenCalledWith(monitor, undefined);
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
@@ -73,7 +73,18 @@ describe("NetworkService", () => {
await service.requestStatus(monitor);
expect(httpProvider.handle).not.toHaveBeenCalled();
expect(pingProvider.handle).toHaveBeenCalledWith(monitor);
expect(pingProvider.handle).toHaveBeenCalledWith(monitor, undefined);
});
it("forwards the check context to the provider", async () => {
const provider = createMockProvider("http");
const { service } = createService([provider]);
const monitor = { type: "http", _id: "mon-1" } as unknown as Monitor & { type: "http" };
const ctx = { proxyUrl: "http://proxy.example.com:8080" };
await service.requestStatus(monitor, ctx);
expect(provider.handle).toHaveBeenCalledWith(monitor, ctx);
});
it("returns unsupported-type response when no provider matches", async () => {
@@ -0,0 +1,248 @@
import { describe, expect, it, jest } from "@jest/globals";
import { ProxiesService } from "../../../src/domain/proxies/proxy.service.ts";
import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createProxiesRepo = () => ({
create: jest.fn(),
findAll: jest.fn(),
findById: jest.fn(),
findByIdOrNull: jest.fn(),
findByTeamId: jest.fn(),
updateById: jest.fn(),
deleteById: jest.fn(),
});
const createMonitorsRepo = () => ({
findMonitorCountByProxyId: jest.fn(),
});
const createSettingsService = () => ({
getDBSettings: jest.fn(),
});
const createService = () => {
const proxiesRepository = createProxiesRepo();
const monitorsRepository = createMonitorsRepo();
const settingsService = createSettingsService();
(monitorsRepository.findMonitorCountByProxyId as jest.Mock).mockResolvedValue(0);
(settingsService.getDBSettings as jest.Mock).mockResolvedValue({ globalProxyId: null });
const service = new ProxiesService(proxiesRepository as any, monitorsRepository as any, settingsService as any);
return { service, proxiesRepository, monitorsRepository, settingsService };
};
const makeProxy = (overrides?: Partial<Proxy>): Proxy =>
({
id: "proxy-1",
teamId: "team-1",
name: "Egress proxy",
protocol: "http",
host: "proxy.internal",
port: 3128,
username: "user",
password: "secret",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Proxy;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("ProxiesService", () => {
describe("createProxy", () => {
it("sets teamId and delegates to repository", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy());
await service.createProxy({ name: "Egress proxy", protocol: "http", host: "proxy.internal", port: 3128 }, "team-1");
expect(proxiesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ teamId: "team-1", name: "Egress proxy" }));
});
it("omits password from the response and reports hasPassword", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy());
const result = await service.createProxy({ name: "Egress proxy" }, "team-1");
expect(result).not.toHaveProperty("password");
expect(result.hasPassword).toBe(true);
});
it("strips blank credentials before creating", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy({ username: undefined, password: undefined }));
await service.createProxy({ name: "Egress proxy", username: "", password: "" }, "team-1");
const created = (proxiesRepository.create as jest.Mock).mock.calls[0][0] as Partial<Proxy>;
expect(created).not.toHaveProperty("username");
expect(created).not.toHaveProperty("password");
});
});
describe("getProxy", () => {
it("delegates to repository and masks the password", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.findById as jest.Mock).mockResolvedValue(makeProxy());
const result = await service.getProxy("proxy-1", "team-1");
expect(proxiesRepository.findById).toHaveBeenCalledWith("proxy-1", "team-1");
expect(result).not.toHaveProperty("password");
expect(result.hasPassword).toBe(true);
});
it("reports hasPassword false when none is stored", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.findById as jest.Mock).mockResolvedValue(makeProxy({ password: undefined }));
const result = await service.getProxy("proxy-1", "team-1");
expect(result.hasPassword).toBe(false);
});
});
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();
(proxiesRepository.findByTeamId as jest.Mock).mockResolvedValue([makeProxy(), makeProxy({ id: "proxy-2", password: undefined })]);
const result = await service.getProxiesByTeamId("team-1");
expect(proxiesRepository.findByTeamId).toHaveBeenCalledWith("team-1");
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("updateProxy", () => {
it("keeps the stored password when the patch has a blank one", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy());
await service.updateProxy("proxy-1", "team-1", { name: "Renamed", password: "", username: "" });
const patch = (proxiesRepository.updateById as jest.Mock).mock.calls[0][2] as Partial<Proxy>;
expect(patch).not.toHaveProperty("password");
expect(patch).not.toHaveProperty("username");
expect(patch.name).toBe("Renamed");
});
it("passes a new password through", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy({ password: "next" }));
await service.updateProxy("proxy-1", "team-1", { password: "next" });
const patch = (proxiesRepository.updateById as jest.Mock).mock.calls[0][2] as Partial<Proxy>;
expect(patch.password).toBe("next");
});
it("maps clear flags to unset options and keeps them out of the patch", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy({ username: undefined, password: undefined }));
await service.updateProxy("proxy-1", "team-1", { name: "Renamed", clearPassword: true, clearUsername: true });
const [, , patch, options] = (proxiesRepository.updateById as jest.Mock).mock.calls[0];
expect(patch).not.toHaveProperty("clearPassword");
expect(patch).not.toHaveProperty("clearUsername");
expect(options).toEqual({ unsetPassword: true, unsetUsername: true });
});
it("does not unset when clear flags are absent", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy());
await service.updateProxy("proxy-1", "team-1", { name: "Renamed" });
const options = (proxiesRepository.updateById as jest.Mock).mock.calls[0][3];
expect(options).toEqual({ unsetPassword: false, unsetUsername: false });
});
it("masks the updated proxy in the response", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy());
const result = await service.updateProxy("proxy-1", "team-1", { name: "Renamed" });
expect(result).not.toHaveProperty("password");
expect(result.hasPassword).toBe(true);
});
});
describe("deleteProxy", () => {
it("deletes when unreferenced and masks the response", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.deleteById as jest.Mock).mockResolvedValue(makeProxy());
const result = await service.deleteProxy("proxy-1", "team-1");
expect(proxiesRepository.deleteById).toHaveBeenCalledWith("proxy-1", "team-1");
expect(result).not.toHaveProperty("password");
});
it("rejects with 409 when monitors reference the proxy", async () => {
const { service, proxiesRepository, monitorsRepository } = createService();
(monitorsRepository.findMonitorCountByProxyId as jest.Mock).mockResolvedValue(3);
await expect(service.deleteProxy("proxy-1", "team-1")).rejects.toMatchObject({ status: 409 });
expect(proxiesRepository.deleteById).not.toHaveBeenCalled();
});
it("rejects with 409 when the proxy is the global proxy", async () => {
const { service, proxiesRepository, settingsService } = createService();
(settingsService.getDBSettings as jest.Mock).mockResolvedValue({ globalProxyId: "proxy-1" });
await expect(service.deleteProxy("proxy-1", "team-1")).rejects.toMatchObject({ status: 409 });
expect(proxiesRepository.deleteById).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,267 @@
import { describe, expect, it, jest } from "@jest/globals";
import { ProxyResolver, buildProxyUrl } from "../../../src/service/network/ProxyResolver.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "m1",
teamId: "team",
type: "http",
proxyMode: "inherit",
...overrides,
}) as Monitor;
const makeProxy = (overrides?: Partial<Proxy>): Proxy =>
({
id: "p1",
teamId: "team",
name: "corp",
protocol: "http",
host: "proxy.example.com",
port: 8080,
...overrides,
}) as Proxy;
const createResolver = (overrides?: Record<string, any>) => {
const defaults = {
proxiesRepository: { findByIdOrNull: jest.fn<() => Promise<Proxy | null>>().mockResolvedValue(makeProxy()) },
settingsService: { getDBSettings: jest.fn<() => Promise<any>>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: "p1" }) },
logger: createMockLogger(),
ttlMs: 60_000,
...overrides,
};
const resolver = new ProxyResolver(defaults.proxiesRepository as any, defaults.settingsService as any, defaults.logger as any, defaults.ttlMs);
return { resolver, defaults };
};
describe("ProxyResolver", () => {
// ── gates ─────────────────────────────────────────────────────────────────
it("returns undefined for non-http monitors without touching repo or settings", async () => {
const { resolver, defaults } = createResolver();
const result = await resolver.resolve(makeMonitor({ type: "port", proxyMode: "custom", proxyId: "p1" } as Partial<Monitor>));
expect(result).toBeUndefined();
expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled();
expect(defaults.settingsService.getDBSettings).not.toHaveBeenCalled();
});
it("returns undefined for proxyMode 'none' without calling settings", async () => {
const { resolver, defaults } = createResolver();
const result = await resolver.resolve(makeMonitor({ proxyMode: "none" }));
expect(result).toBeUndefined();
expect(defaults.settingsService.getDBSettings).not.toHaveBeenCalled();
});
// ── custom mode ───────────────────────────────────────────────────────────
it("resolves a custom proxy with a team-scoped lookup", async () => {
const { resolver, defaults } = createResolver();
const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" }));
expect(result).toBe("http://proxy.example.com:8080");
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("p1", "team");
});
it("returns undefined and warns when the custom proxy no longer exists", async () => {
const { resolver, defaults } = createResolver({
proxiesRepository: { findByIdOrNull: jest.fn<() => Promise<Proxy | null>>().mockResolvedValue(null) },
});
const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" }));
expect(result).toBeUndefined();
expect(defaults.logger.warn).toHaveBeenCalled();
});
it("returns undefined and warns when proxyMode is custom but proxyId is missing, without a repo call", async () => {
const { resolver, defaults } = createResolver();
const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: undefined }));
expect(result).toBeUndefined();
expect(defaults.logger.warn).toHaveBeenCalled();
expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled();
});
// ── inherit mode ──────────────────────────────────────────────────────────
it("returns undefined when the global proxy is disabled, without a repo call", async () => {
const { resolver, defaults } = createResolver({
settingsService: { getDBSettings: jest.fn<() => Promise<any>>().mockResolvedValue({ globalProxyEnabled: false, globalProxyId: "p1" }) },
});
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
expect(result).toBeUndefined();
expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled();
});
it("resolves the global proxy with an unscoped lookup when enabled", async () => {
const { resolver, defaults } = createResolver();
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
expect(result).toBe("http://proxy.example.com:8080");
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("p1");
});
it("returns undefined when the global proxy is enabled but globalProxyId is null, without a repo call", async () => {
const { resolver, defaults } = createResolver({
settingsService: { getDBSettings: jest.fn<() => Promise<any>>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: null }) },
});
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
expect(result).toBeUndefined();
expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled();
});
it("returns undefined and warns when the global proxy references a proxy that no longer exists", async () => {
const { resolver, defaults } = createResolver({
proxiesRepository: { findByIdOrNull: jest.fn<() => Promise<Proxy | null>>().mockResolvedValue(null) },
});
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
expect(result).toBeUndefined();
expect(defaults.logger.warn).toHaveBeenCalled();
});
it("treats an unknown proxyMode as inherit", async () => {
const { resolver, defaults } = createResolver();
const result = await resolver.resolve(makeMonitor({ proxyMode: "garbage" as Monitor["proxyMode"] }));
expect(result).toBe("http://proxy.example.com:8080");
expect(defaults.settingsService.getDBSettings).toHaveBeenCalled();
});
// ── never throws ──────────────────────────────────────────────────────────
it("returns undefined and warns when the repository throws", async () => {
const { resolver, defaults } = createResolver({
proxiesRepository: { findByIdOrNull: jest.fn<() => Promise<Proxy | null>>().mockRejectedValue(new Error("db down")) },
});
const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" }));
expect(result).toBeUndefined();
expect(defaults.logger.warn).toHaveBeenCalled();
});
it("returns undefined and warns when the settings service throws", async () => {
const { resolver, defaults } = createResolver({
settingsService: { getDBSettings: jest.fn<() => Promise<any>>().mockRejectedValue(new Error("db down")) },
});
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
expect(result).toBeUndefined();
expect(defaults.logger.warn).toHaveBeenCalled();
});
// ── caching ───────────────────────────────────────────────────────────────
it("serves a second resolve within the TTL from cache", async () => {
const { resolver, defaults } = createResolver();
await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(1);
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1);
});
it("refetches after the TTL expires", async () => {
const { resolver, defaults } = createResolver({ ttlMs: 0 });
await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(2);
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(2);
});
it("negative-caches a dangling ref, costing one repo call per TTL window", async () => {
const { resolver, defaults } = createResolver({
proxiesRepository: { findByIdOrNull: jest.fn<() => Promise<Proxy | null>>().mockResolvedValue(null) },
});
await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" }));
await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" }));
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1);
});
it("shares one in-flight fetch between concurrent resolves", async () => {
const { resolver, defaults } = createResolver();
await Promise.all([
resolver.resolve(makeMonitor({ proxyMode: "inherit" })),
resolver.resolve(makeMonitor({ proxyMode: "inherit" })),
resolver.resolve(makeMonitor({ proxyMode: "inherit" })),
]);
expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(1);
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1);
});
it("does not cache a rejected fetch — the next resolve retries", async () => {
const findByIdOrNull = jest.fn<() => Promise<Proxy | null>>().mockRejectedValueOnce(new Error("db down")).mockResolvedValueOnce(makeProxy());
const { resolver } = createResolver({ proxiesRepository: { findByIdOrNull } });
const first = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" }));
const second = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" }));
expect(first).toBeUndefined();
expect(second).toBe("http://proxy.example.com:8080");
expect(findByIdOrNull).toHaveBeenCalledTimes(2);
});
it("does not let a scoped miss poison the unscoped global lookup for the same proxy id", async () => {
const findByIdOrNull = jest.fn<(proxyId: string, teamId?: string) => Promise<Proxy | null>>(async (_proxyId, teamId) =>
teamId ? null : makeProxy()
);
const { resolver } = createResolver({ proxiesRepository: { findByIdOrNull } });
const customResult = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" }));
const inheritResult = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
expect(customResult).toBeUndefined();
expect(inheritResult).toBe("http://proxy.example.com:8080");
expect(findByIdOrNull).toHaveBeenCalledTimes(2);
});
});
describe("buildProxyUrl", () => {
it("builds a URL without credentials when there is no username", () => {
expect(buildProxyUrl(makeProxy())).toBe("http://proxy.example.com:8080");
});
it("builds a URL with username and password", () => {
expect(buildProxyUrl(makeProxy({ username: "user", password: "pass" }))).toBe("http://user:pass@proxy.example.com:8080");
});
it("percent-encodes credentials", () => {
expect(buildProxyUrl(makeProxy({ username: "user@x", password: "p@ss" }))).toBe("http://user%40x:p%40ss@proxy.example.com:8080");
});
it("builds a URL with a username only, without a colon", () => {
expect(buildProxyUrl(makeProxy({ username: "user" }))).toBe("http://user@proxy.example.com:8080");
});
it("emits no credentials for a password without a username", () => {
expect(buildProxyUrl(makeProxy({ password: "pass" }))).toBe("http://proxy.example.com:8080");
});
it("uses the https scheme without a trailing slash", () => {
expect(buildProxyUrl(makeProxy({ protocol: "https" }))).toBe("https://proxy.example.com:8080");
});
});
@@ -0,0 +1,101 @@
import { describe, expect, it } from "@jest/globals";
import { fetchMonitorCertificate } from "../../../src/api/controllers/controllerUtils.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
import type { SSLDetails, SSLOptions } from "ssl-checker";
const VALID_CERT: SSLDetails = {
daysRemaining: 30,
valid: true,
validFrom: "2026-01-01T00:00:00.000Z",
validTo: "2026-12-31T00:00:00.000Z",
validFor: ["example.com"],
};
/** Records the arguments the checker was called with. */
const spyChecker = (cert: SSLDetails = VALID_CERT) => {
const calls: Array<{ hostname: string; options?: SSLOptions }> = [];
const checker = (async (hostname: string, options?: SSLOptions) => {
calls.push({ hostname, options });
return cert;
}) as unknown as typeof import("ssl-checker").default;
return { checker, calls };
};
const monitorWithUrl = (url: string) => ({ url }) as Monitor;
describe("fetchMonitorCertificate", () => {
it("probes the port named in the monitor URL", async () => {
const { checker, calls } = spyChecker();
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:54321"));
expect(calls).toHaveLength(1);
expect(calls[0].hostname).toBe("example.com");
expect(calls[0].options?.port).toBe(54321);
});
it("falls back to 443 when the URL names no port", async () => {
const { checker, calls } = spyChecker();
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com"));
expect(calls[0].options?.port).toBe(443);
});
it("passes an explicit 443 through unchanged", async () => {
const { checker, calls } = spyChecker();
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:443"));
expect(calls[0].options?.port).toBe(443);
});
it("bounds the probe with a timeout so a user-defined port cannot hang it", async () => {
const { checker, calls } = spyChecker();
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:54321"));
expect(calls[0].options?.timeout).toBe(10_000);
});
it("never passes NaN as the port", async () => {
const { checker, calls } = spyChecker();
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com"));
expect(Number.isNaN(calls[0].options?.port)).toBe(false);
});
it("rejects a plain-HTTP monitor without opening a TLS connection", async () => {
const { checker, calls } = spyChecker();
// Probing a non-TLS port would surface a raw OpenSSL handshake error.
await expect(fetchMonitorCertificate(checker, monitorWithUrl("http://example.com:8080"))).rejects.toThrow(
"Certificate is only available for HTTPS monitors"
);
expect(calls).toHaveLength(0);
});
it("rejects a plain-HTTP monitor on the default port", async () => {
const { checker, calls } = spyChecker();
await expect(fetchMonitorCertificate(checker, monitorWithUrl("http://example.com"))).rejects.toThrow(
"Certificate is only available for HTTPS monitors"
);
expect(calls).toHaveLength(0);
});
it("returns the certificate details it received", async () => {
const { checker } = spyChecker();
const cert = await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:8443"));
expect(cert.validTo).toBe(VALID_CERT.validTo);
});
it("throws when the certificate has no expiry", async () => {
const { checker } = spyChecker({ ...VALID_CERT, validTo: undefined as unknown as string });
await expect(fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:8443"))).rejects.toThrow("Certificate not found");
});
});
@@ -470,3 +470,72 @@ describe("monitorValidation — customUpCodes", () => {
});
});
});
describe("monitorValidation — proxy fields", () => {
const baseHttpBody = {
name: "HTTP check",
type: "http" as const,
url: "https://example.com",
};
describe("createMonitorBodyValidation", () => {
it("defaults proxyMode to inherit", () => {
const parsed = createMonitorBodyValidation.parse(baseHttpBody);
expect(parsed.proxyMode).toBe("inherit");
expect(parsed.proxyId).toBeUndefined();
});
it("accepts custom mode with a proxyId", () => {
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "proxy-1" });
expect(parsed.proxyMode).toBe("custom");
expect(parsed.proxyId).toBe("proxy-1");
});
it("rejects custom mode without a proxyId", () => {
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom" })).toThrow();
});
it("rejects an unknown proxyMode", () => {
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "global" })).toThrow();
});
it("allows a stray proxyId on non-custom modes", () => {
for (const proxyMode of ["inherit", "none"] as const) {
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: "proxy-1" });
expect(parsed.proxyMode).toBe(proxyMode);
}
});
});
describe("editMonitorBodyValidation", () => {
it("leaves proxyMode unset when omitted (no default injected on PATCH)", () => {
const parsed = editMonitorBodyValidation.parse({ name: "Renamed" });
expect(parsed.proxyMode).toBeUndefined();
});
it("rejects custom mode without a proxyId", () => {
expect(() => editMonitorBodyValidation.parse({ proxyMode: "custom" })).toThrow();
});
it("accepts custom mode with a proxyId", () => {
const parsed = editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: "proxy-1" });
expect(parsed.proxyId).toBe("proxy-1");
});
});
describe("importMonitorsBodyValidation", () => {
it("defaults proxyMode to inherit on import", () => {
const parsed = importMonitorsBodyValidation.parse({ monitors: [baseHttpBody] });
expect(parsed.monitors[0].proxyMode).toBe("inherit");
});
it("rejects an imported monitor with custom mode and no proxyId", () => {
expect(() => importMonitorsBodyValidation.parse({ monitors: [{ ...baseHttpBody, proxyMode: "custom" }] })).toThrow();
});
});
});
@@ -0,0 +1,97 @@
import { describe, expect, it } from "@jest/globals";
import { createProxyBodyValidation, editProxyBodyValidation, proxyResponseSchema } from "../../../src/api/validation/proxyValidation.ts";
const validBody = {
name: "Egress proxy",
protocol: "http" as const,
host: "proxy.internal",
port: 3128,
};
describe("proxyValidation", () => {
describe("createProxyBodyValidation", () => {
it("accepts a minimal valid body", () => {
const parsed = createProxyBodyValidation.parse(validBody);
expect(parsed).toEqual(validBody);
});
it("accepts optional credentials", () => {
const parsed = createProxyBodyValidation.parse({ ...validBody, username: "user", password: "secret" });
expect(parsed.username).toBe("user");
expect(parsed.password).toBe("secret");
});
it("rejects an empty name", () => {
expect(() => createProxyBodyValidation.parse({ ...validBody, name: "" })).toThrow();
});
it("rejects an empty host", () => {
expect(() => createProxyBodyValidation.parse({ ...validBody, host: "" })).toThrow();
});
it("rejects a protocol outside the tuple", () => {
expect(() => createProxyBodyValidation.parse({ ...validBody, protocol: "socks5" })).toThrow();
});
it("accepts the port boundaries", () => {
expect(createProxyBodyValidation.parse({ ...validBody, port: 1 }).port).toBe(1);
expect(createProxyBodyValidation.parse({ ...validBody, port: 65535 }).port).toBe(65535);
});
it("rejects out-of-range and non-integer ports", () => {
for (const port of [0, -1, 65536, 3128.5, "3128"]) {
expect(() => createProxyBodyValidation.parse({ ...validBody, port })).toThrow();
}
});
});
describe("editProxyBodyValidation", () => {
it("accepts the create shape without clear flags", () => {
const parsed = editProxyBodyValidation.parse(validBody);
expect(parsed.clearPassword).toBeUndefined();
expect(parsed.clearUsername).toBeUndefined();
});
it("accepts clearPassword and clearUsername", () => {
const parsed = editProxyBodyValidation.parse({ ...validBody, clearPassword: true, clearUsername: true });
expect(parsed.clearPassword).toBe(true);
expect(parsed.clearUsername).toBe(true);
});
it("rejects non-boolean clear flags", () => {
expect(() => editProxyBodyValidation.parse({ ...validBody, clearPassword: "yes" })).toThrow();
});
});
describe("proxyResponseSchema", () => {
const validResponse = {
id: "proxy-1",
teamId: "team-1",
name: "Egress proxy",
protocol: "http",
host: "proxy.internal",
port: 3128,
hasPassword: false,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
it("accepts a masked proxy", () => {
expect(() => proxyResponseSchema.parse(validResponse)).not.toThrow();
});
it("requires hasPassword", () => {
const { hasPassword: _hasPassword, ...withoutFlag } = validResponse;
expect(() => proxyResponseSchema.parse(withoutFlag)).toThrow();
});
it("does not declare a password field", () => {
expect(Object.keys(proxyResponseSchema.shape)).not.toContain("password");
});
});
});