-
-
- {form.errors.forwardScheme ? (
-
- {form.errors.forwardScheme &&
- form.touched.forwardScheme
- ? form.errors.forwardScheme
- : null}
-
- ) : null}
+
+
+
+
+
+
diff --git a/frontend/src/pages/Settings/RealIpHeader.tsx b/frontend/src/pages/Settings/RealIpHeader.tsx
new file mode 100644
index 0000000000..2dcabb2226
--- /dev/null
+++ b/frontend/src/pages/Settings/RealIpHeader.tsx
@@ -0,0 +1,164 @@
+import { Field, Form, Formik } from "formik";
+import { type ReactNode, useState } from "react";
+import { Alert } from "react-bootstrap";
+import { Button, Loading } from "src/components";
+import { useSetSetting, useSetting } from "src/hooks";
+import { intl, T } from "src/locale";
+import { showObjectSuccess } from "src/notifications";
+
+const HEADER_OPTIONS = [
+ { value: "X-Real-IP", localeId: "settings.real-ip-header.x-real-ip", descId: "settings.real-ip-header.x-real-ip.description" },
+ { value: "CF-Connecting-IP", localeId: "settings.real-ip-header.cf-connecting-ip", descId: "settings.real-ip-header.cf-connecting-ip.description" },
+ { value: "X-Forwarded-For", localeId: "settings.real-ip-header.x-forwarded-for", descId: "settings.real-ip-header.x-forwarded-for.description" },
+ { value: "custom", localeId: "settings.real-ip-header.custom", descId: null },
+];
+
+export default function RealIpHeader() {
+ const { data, isLoading, error } = useSetting("real-ip-header");
+ const { mutate: setSetting } = useSetSetting();
+ const [errorMsg, setErrorMsg] = useState
(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const onSubmit = async (values: any, { setSubmitting }: any) => {
+ if (isSubmitting) return;
+ setIsSubmitting(true);
+ setErrorMsg(null);
+
+ const payload = {
+ id: "real-ip-header",
+ value: values.value,
+ meta: {
+ custom: values.custom,
+ },
+ };
+
+ setSetting(payload, {
+ onError: (err: any) => setErrorMsg(),
+ onSuccess: () => {
+ showObjectSuccess("setting", "saved");
+ },
+ onSettled: () => {
+ setIsSubmitting(false);
+ setSubmitting(false);
+ },
+ });
+ };
+
+ if (!isLoading && error) {
+ return (
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {({ values }) => (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/UpstreamHosts/Table.tsx b/frontend/src/pages/UpstreamHosts/Table.tsx
new file mode 100644
index 0000000000..6971aa48f6
--- /dev/null
+++ b/frontend/src/pages/UpstreamHosts/Table.tsx
@@ -0,0 +1,150 @@
+import { IconDotsVertical, IconEdit, IconTrash } from "@tabler/icons-react";
+import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
+import { useMemo } from "react";
+import type { UpstreamHost } from "src/api/backend";
+import { EmptyData, GravatarFormatter, HasPermission, ValueWithDateFormatter } from "src/components";
+import { TableLayout } from "src/components/Table/TableLayout";
+import { intl, T } from "src/locale";
+import { MANAGE, UPSTREAM_HOSTS } from "src/modules/Permissions";
+
+interface Props {
+ data: UpstreamHost[];
+ isFiltered?: boolean;
+ isFetching?: boolean;
+ onEdit?: (id: number) => void;
+ onDelete?: (id: number) => void;
+ onNew?: () => void;
+}
+export default function Table({ data, isFetching, isFiltered, onEdit, onDelete, onNew }: Props) {
+ const columnHelper = createColumnHelper();
+ const columns = useMemo(
+ () => [
+ columnHelper.accessor((row: any) => row.owner, {
+ id: "owner",
+ cell: (info: any) => {
+ const value = info.getValue();
+ return ;
+ },
+ meta: {
+ className: "w-1",
+ },
+ }),
+ columnHelper.accessor((row: any) => row, {
+ id: "name",
+ header: intl.formatMessage({ id: "column.name" }),
+ cell: (info: any) => (
+
+ ),
+ }),
+ columnHelper.accessor((row: any) => row.forwardScheme, {
+ id: "forwardScheme",
+ header: intl.formatMessage({ id: "column.scheme" }),
+ cell: (info: any) => info.getValue(),
+ }),
+ columnHelper.accessor((row: any) => row.method, {
+ id: "method",
+ header: intl.formatMessage({ id: "upstream-host.method" }),
+ cell: (info: any) => {
+ const method = info.getValue();
+ return method?.replace(/_/g, " ") || "";
+ },
+ }),
+ columnHelper.accessor((row: any) => row.servers, {
+ id: "servers",
+ header: intl.formatMessage({ id: "upstream-host.servers" }),
+ cell: (info: any) => {
+ const servers = info.getValue();
+ return servers?.length || 0;
+ },
+ }),
+ columnHelper.accessor((row: any) => row.proxyHostCount, {
+ id: "proxyHostCount",
+ header: intl.formatMessage({ id: "proxy-hosts" }),
+ cell: (info: any) => ,
+ }),
+ columnHelper.display({
+ id: "id",
+ cell: (info: any) => {
+ return (
+
+
+
+
+ );
+ },
+ meta: {
+ className: "text-end w-1",
+ },
+ }),
+ ],
+ [columnHelper, onEdit, onDelete],
+ );
+
+ const tableInstance = useReactTable({
+ columns,
+ data,
+ getCoreRowModel: getCoreRowModel(),
+ rowCount: data.length,
+ meta: {
+ isFetching,
+ },
+ enableSortingRemoval: false,
+ });
+
+ return (
+
+ }
+ />
+ );
+}
diff --git a/frontend/src/pages/UpstreamHosts/TableWrapper.tsx b/frontend/src/pages/UpstreamHosts/TableWrapper.tsx
new file mode 100644
index 0000000000..0bb41d2111
--- /dev/null
+++ b/frontend/src/pages/UpstreamHosts/TableWrapper.tsx
@@ -0,0 +1,100 @@
+import { IconSearch } from "@tabler/icons-react";
+import { useState } from "react";
+import Alert from "react-bootstrap/Alert";
+import { deleteUpstreamHost } from "src/api/backend";
+import { Button, HasPermission, LoadingPage } from "src/components";
+import { useUpstreamHosts } from "src/hooks";
+import { T } from "src/locale";
+import { showDeleteConfirmModal, showUpstreamHostModal } from "src/modals";
+import { MANAGE, UPSTREAM_HOSTS } from "src/modules/Permissions";
+import { showObjectSuccess } from "src/notifications";
+import Table from "./Table";
+
+export default function TableWrapper() {
+ const [search, setSearch] = useState("");
+ const { isFetching, isLoading, isError, error, data } = useUpstreamHosts(["owner", "servers"]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (isError) {
+ return {error?.message || "Unknown error"};
+ }
+
+ const handleDelete = async (id: number) => {
+ await deleteUpstreamHost(id);
+ showObjectSuccess("upstream-host", "deleted");
+ };
+
+ let filtered = null;
+ if (search && data) {
+ filtered = data?.filter((item) => {
+ return item.name.toLowerCase().includes(search);
+ });
+ } else if (search !== "") {
+ setSearch("");
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {data?.length ? (
+
+
+
+
+ setSearch(e.target.value.toLowerCase().trim())}
+ />
+
+ ) : null}
+
+ {data?.length ? (
+
+ ) : null}
+
+
+
+
+
+
showUpstreamHostModal(id)}
+ onDelete={(id: number) =>
+ showDeleteConfirmModal({
+ title: ,
+ onConfirm: () => handleDelete(id),
+ invalidations: [["upstream-hosts"], ["upstream-host", id]],
+ children: ,
+ })
+ }
+ onNew={() => showUpstreamHostModal("new")}
+ />
+
+
+ );
+}
diff --git a/frontend/src/pages/UpstreamHosts/index.tsx b/frontend/src/pages/UpstreamHosts/index.tsx
new file mode 100644
index 0000000000..1d7439c29e
--- /dev/null
+++ b/frontend/src/pages/UpstreamHosts/index.tsx
@@ -0,0 +1,13 @@
+import { HasPermission } from "src/components";
+import { UPSTREAM_HOSTS, VIEW } from "src/modules/Permissions";
+import TableWrapper from "./TableWrapper";
+
+const UpstreamHosts = () => {
+ return (
+
+
+
+ );
+};
+
+export default UpstreamHosts;
diff --git a/test/cypress/e2e/api/RealIpHeader.cy.js b/test/cypress/e2e/api/RealIpHeader.cy.js
new file mode 100644
index 0000000000..365ece4ff6
--- /dev/null
+++ b/test/cypress/e2e/api/RealIpHeader.cy.js
@@ -0,0 +1,124 @@
+///
+
+describe('Real IP Header setting endpoints', () => {
+ let token;
+
+ before(() => {
+ cy.getToken().then((tok) => {
+ token = tok;
+ });
+ });
+
+ it('Should include real-ip-header in all settings', () => {
+ cy.task('backendApiGet', {
+ token: token,
+ path: '/api/settings',
+ }).then((data) => {
+ cy.validateSwaggerSchema('get', 200, '/settings', data);
+ const realIpSetting = data.find((s) => s.id === 'real-ip-header');
+ expect(realIpSetting).to.not.be.undefined;
+ expect(realIpSetting).to.have.property('value', 'X-Real-IP');
+ });
+ });
+
+ it('Should get real-ip-header setting', () => {
+ cy.task('backendApiGet', {
+ token: token,
+ path: '/api/settings/real-ip-header',
+ }).then((data) => {
+ cy.validateSwaggerSchema('get', 200, '/settings/{settingID}', data);
+ expect(data).to.have.property('id', 'real-ip-header');
+ expect(data).to.have.property('value', 'X-Real-IP');
+ expect(data).to.have.property('name', 'Real IP Header');
+ });
+ });
+
+ it('Should set real-ip-header to X-Real-IP', () => {
+ cy.task('backendApiPut', {
+ token: token,
+ path: '/api/settings/real-ip-header',
+ data: {
+ value: 'X-Real-IP',
+ meta: {},
+ },
+ }).then((data) => {
+ cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data);
+ expect(data).to.have.property('id', 'real-ip-header');
+ expect(data).to.have.property('value', 'X-Real-IP');
+ });
+ });
+
+ it('Should set real-ip-header to CF-Connecting-IP', () => {
+ cy.task('backendApiPut', {
+ token: token,
+ path: '/api/settings/real-ip-header',
+ data: {
+ value: 'CF-Connecting-IP',
+ meta: {},
+ },
+ }).then((data) => {
+ cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data);
+ expect(data).to.have.property('id', 'real-ip-header');
+ expect(data).to.have.property('value', 'CF-Connecting-IP');
+ });
+ });
+
+ it('Should set real-ip-header to X-Forwarded-For', () => {
+ cy.task('backendApiPut', {
+ token: token,
+ path: '/api/settings/real-ip-header',
+ data: {
+ value: 'X-Forwarded-For',
+ meta: {},
+ },
+ }).then((data) => {
+ cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data);
+ expect(data).to.have.property('id', 'real-ip-header');
+ expect(data).to.have.property('value', 'X-Forwarded-For');
+ });
+ });
+
+ it('Should set real-ip-header to custom value', () => {
+ cy.task('backendApiPut', {
+ token: token,
+ path: '/api/settings/real-ip-header',
+ data: {
+ value: 'custom',
+ meta: {
+ custom: 'True-Client-IP',
+ },
+ },
+ }).then((data) => {
+ cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data);
+ expect(data).to.have.property('id', 'real-ip-header');
+ expect(data).to.have.property('value', 'custom');
+ expect(data).to.have.property('meta');
+ expect(data.meta).to.have.property('custom', 'True-Client-IP');
+ });
+ });
+
+ it('Should persist the value after re-reading', () => {
+ cy.task('backendApiGet', {
+ token: token,
+ path: '/api/settings/real-ip-header',
+ }).then((data) => {
+ cy.validateSwaggerSchema('get', 200, '/settings/{settingID}', data);
+ expect(data).to.have.property('value', 'custom');
+ expect(data.meta).to.have.property('custom', 'True-Client-IP');
+ });
+ });
+
+ it('Should reset back to X-Real-IP', () => {
+ cy.task('backendApiPut', {
+ token: token,
+ path: '/api/settings/real-ip-header',
+ data: {
+ value: 'X-Real-IP',
+ meta: {},
+ },
+ }).then((data) => {
+ cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data);
+ expect(data).to.have.property('value', 'X-Real-IP');
+ });
+ });
+});
diff --git a/test/cypress/e2e/api/UpstreamHosts.cy.js b/test/cypress/e2e/api/UpstreamHosts.cy.js
new file mode 100644
index 0000000000..21d420f9fe
--- /dev/null
+++ b/test/cypress/e2e/api/UpstreamHosts.cy.js
@@ -0,0 +1,382 @@
+///
+
+describe('Upstream Hosts endpoints', () => {
+ let token;
+ let upstreamHostId;
+
+ before(() => {
+ cy.getToken().then((tok) => {
+ token = tok;
+ });
+ });
+
+ it('Should be able to create a round-robin upstream host', () => {
+ cy.task('backendApiPost', {
+ token: token,
+ path: '/api/nginx/upstream-hosts',
+ data: {
+ name: 'Test Upstream RR',
+ forward_scheme: 'http',
+ method: 'round_robin',
+ servers: [
+ { host: '10.0.0.1', port: 8080, weight: 1 },
+ { host: '10.0.0.2', port: 8080, weight: 1 },
+ ],
+ meta: {},
+ }
+ }).then((data) => {
+ cy.validateSwaggerSchema('post', 201, '/nginx/upstream-hosts', data);
+ expect(data).to.have.property('id');
+ expect(data.id).to.be.greaterThan(0);
+ expect(data).to.have.property('name', 'Test Upstream RR');
+ expect(data).to.have.property('forward_scheme', 'http');
+ expect(data).to.have.property('method', 'round_robin');
+ expect(data).to.have.property('servers');
+ expect(data.servers).to.have.length(2);
+ const hosts = data.servers.map((s) => s.host).sort();
+ expect(hosts).to.deep.equal(['10.0.0.1', '10.0.0.2']);
+ expect(data.servers[0]).to.have.property('port', 8080);
+ upstreamHostId = data.id;
+ });
+ });
+
+ it('Should be able to create a least-conn upstream host', () => {
+ cy.task('backendApiPost', {
+ token: token,
+ path: '/api/nginx/upstream-hosts',
+ data: {
+ name: 'Test Upstream LC',
+ forward_scheme: 'https',
+ method: 'least_conn',
+ servers: [
+ { host: '10.0.0.3', port: 443, weight: 5 },
+ { host: '10.0.0.4', port: 443, weight: 10 },
+ ],
+ meta: {},
+ }
+ }).then((data) => {
+ cy.validateSwaggerSchema('post', 201, '/nginx/upstream-hosts', data);
+ expect(data).to.have.property('id');
+ expect(data.id).to.be.greaterThan(0);
+ expect(data).to.have.property('method', 'least_conn');
+ expect(data).to.have.property('forward_scheme', 'https');
+ expect(data.servers[0]).to.have.property('weight', 5);
+ expect(data.servers[1]).to.have.property('weight', 10);
+ });
+ });
+
+ it('Should be able to create an ip-hash upstream host', () => {
+ cy.task('backendApiPost', {
+ token: token,
+ path: '/api/nginx/upstream-hosts',
+ data: {
+ name: 'Test Upstream IH',
+ forward_scheme: 'http',
+ method: 'ip_hash',
+ servers: [
+ { host: '10.0.0.5', port: 9000 },
+ ],
+ meta: {},
+ }
+ }).then((data) => {
+ cy.validateSwaggerSchema('post', 201, '/nginx/upstream-hosts', data);
+ expect(data).to.have.property('method', 'ip_hash');
+ expect(data.servers).to.have.length(1);
+ });
+ });
+
+ it('Should be able to list all upstream hosts', () => {
+ cy.task('backendApiGet', {
+ token: token,
+ path: '/api/nginx/upstream-hosts',
+ }).then((data) => {
+ cy.validateSwaggerSchema('get', 200, '/nginx/upstream-hosts', data);
+ expect(data).to.be.an('array');
+ expect(data.length).to.be.greaterThan(0);
+ });
+ });
+
+ it('Should be able to get a specific upstream host', () => {
+ cy.task('backendApiGet', {
+ token: token,
+ path: '/api/nginx/upstream-hosts/' + upstreamHostId,
+ }).then((data) => {
+ cy.validateSwaggerSchema('get', 200, '/nginx/upstream-hosts/{upstreamID}', data);
+ expect(data).to.have.property('id', upstreamHostId);
+ expect(data).to.have.property('name', 'Test Upstream RR');
+ });
+ });
+
+ it('Should be able to update an upstream host', () => {
+ cy.task('backendApiPut', {
+ token: token,
+ path: '/api/nginx/upstream-hosts/' + upstreamHostId,
+ data: {
+ name: 'Updated Upstream',
+ forward_scheme: 'https',
+ method: 'least_conn',
+ servers: [
+ { host: '10.0.0.1', port: 8080, weight: 3 },
+ { host: '10.0.0.2', port: 8080, weight: 7 },
+ { host: '10.0.0.9', port: 8080, weight: 5 },
+ ],
+ meta: {},
+ }
+ }).then((data) => {
+ cy.validateSwaggerSchema('put', 200, '/nginx/upstream-hosts/{upstreamID}', data);
+ expect(data).to.have.property('id', upstreamHostId);
+ expect(data).to.have.property('name', 'Updated Upstream');
+ expect(data).to.have.property('forward_scheme', 'https');
+ expect(data).to.have.property('method', 'least_conn');
+ expect(data.servers).to.have.length(3);
+ });
+ });
+
+ it('Should be able to delete an unused upstream host', () => {
+ cy.task('backendApiPost', {
+ token: token,
+ path: '/api/nginx/upstream-hosts',
+ data: {
+ name: 'To Delete',
+ forward_scheme: 'http',
+ method: 'round_robin',
+ servers: [
+ { host: '10.0.0.99', port: 80 },
+ ],
+ meta: {},
+ }
+ }).then((created) => {
+ cy.task('backendApiDelete', {
+ token: token,
+ path: '/api/nginx/upstream-hosts/' + created.id,
+ }).then((data) => {
+ expect(data).to.be.equal(true);
+ });
+ });
+ });
+});
+
+describe('Proxy Hosts with Upstream Hosts', () => {
+ let token;
+ let upstreamHostId;
+ let proxyHostId;
+
+ before(() => {
+ cy.getToken().then((tok) => {
+ token = tok;
+ cy.task('backendApiPost', {
+ token: token,
+ path: '/api/nginx/upstream-hosts',
+ data: {
+ name: 'Proxy Test Upstream',
+ forward_scheme: 'http',
+ method: 'round_robin',
+ servers: [
+ { host: '10.0.0.50', port: 8080, weight: 1 },
+ { host: '10.0.0.51', port: 8080, weight: 1 },
+ ],
+ meta: {},
+ }
+ }).then((data) => {
+ upstreamHostId = data.id;
+ });
+ });
+ });
+
+ it('Should create a proxy host using an upstream host', () => {
+ cy.task('backendApiPost', {
+ token: token,
+ path: '/api/nginx/proxy-hosts',
+ data: {
+ domain_names: ['upstream-test.example.com'],
+ forward_scheme: 'http',
+ forward_host: '127.0.0.1',
+ forward_port: 80,
+ upstream_host_id: upstreamHostId,
+ access_list_id: '0',
+ certificate_id: 0,
+ meta: {},
+ advanced_config: '',
+ locations: [],
+ block_exploits: false,
+ caching_enabled: false,
+ allow_websocket_upgrade: false,
+ http2_support: false,
+ hsts_enabled: false,
+ hsts_subdomains: false,
+ ssl_forced: false,
+ }
+ }).then((data) => {
+ cy.validateSwaggerSchema('post', 201, '/nginx/proxy-hosts', data);
+ expect(data).to.have.property('id');
+ expect(data.id).to.be.greaterThan(0);
+ expect(data).to.have.property('upstream_host_id', upstreamHostId);
+ proxyHostId = data.id;
+ });
+ });
+
+ it('Should get proxy host with upstream host expanded', () => {
+ cy.task('backendApiGet', {
+ token: token,
+ path: '/api/nginx/proxy-hosts/' + proxyHostId + '?expand=upstream_host',
+ }).then((data) => {
+ expect(data).to.have.property('id', proxyHostId);
+ expect(data).to.have.property('upstream_host_id', upstreamHostId);
+ });
+ });
+
+ it('Should switch proxy host from upstream to direct', () => {
+ cy.task('backendApiPut', {
+ token: token,
+ path: '/api/nginx/proxy-hosts/' + proxyHostId,
+ data: {
+ upstream_host_id: 0,
+ forward_host: '192.168.1.100',
+ forward_port: 3000,
+ }
+ }).then((data) => {
+ cy.validateSwaggerSchema('put', 200, '/nginx/proxy-hosts/{hostID}', data);
+ expect(data).to.have.property('upstream_host_id', 0);
+ expect(data).to.have.property('forward_host', '192.168.1.100');
+ expect(data).to.have.property('forward_port', 3000);
+ });
+ });
+
+ it('Should switch proxy host from direct back to upstream', () => {
+ cy.task('backendApiPut', {
+ token: token,
+ path: '/api/nginx/proxy-hosts/' + proxyHostId,
+ data: {
+ upstream_host_id: upstreamHostId,
+ }
+ }).then((data) => {
+ cy.validateSwaggerSchema('put', 200, '/nginx/proxy-hosts/{hostID}', data);
+ expect(data).to.have.property('upstream_host_id', upstreamHostId);
+ });
+ });
+
+ it('Should not be able to delete upstream host that is in use', () => {
+ cy.task('backendApiDelete', {
+ token: token,
+ path: '/api/nginx/upstream-hosts/' + upstreamHostId,
+ returnOnError: true,
+ }).then((data) => {
+ expect(data).to.have.property('error');
+ });
+ });
+});
+
+describe('Custom Locations with Upstream Hosts', () => {
+ let token;
+ let upstreamHostId;
+
+ before(() => {
+ cy.getToken().then((tok) => {
+ token = tok;
+ cy.task('backendApiPost', {
+ token: token,
+ path: '/api/nginx/upstream-hosts',
+ data: {
+ name: 'Location Test Upstream',
+ forward_scheme: 'http',
+ method: 'round_robin',
+ servers: [
+ { host: '10.0.0.60', port: 9090, weight: 1 },
+ { host: '10.0.0.61', port: 9090, weight: 2 },
+ ],
+ meta: {},
+ }
+ }).then((data) => {
+ upstreamHostId = data.id;
+ });
+ });
+ });
+
+ it('Should create a proxy host with a location using upstream host', () => {
+ cy.task('backendApiPost', {
+ token: token,
+ path: '/api/nginx/proxy-hosts',
+ data: {
+ domain_names: ['location-upstream.example.com'],
+ forward_scheme: 'http',
+ forward_host: '127.0.0.1',
+ forward_port: 80,
+ access_list_id: '0',
+ certificate_id: 0,
+ meta: {},
+ advanced_config: '',
+ locations: [
+ {
+ path: '/api',
+ forward_scheme: 'http',
+ forward_host: '127.0.0.1',
+ forward_port: 80,
+ upstream_host_id: upstreamHostId,
+ upstream_host_forward_scheme: 'http',
+ }
+ ],
+ block_exploits: false,
+ caching_enabled: false,
+ allow_websocket_upgrade: false,
+ http2_support: false,
+ hsts_enabled: false,
+ hsts_subdomains: false,
+ ssl_forced: false,
+ }
+ }).then((data) => {
+ cy.validateSwaggerSchema('post', 201, '/nginx/proxy-hosts', data);
+ expect(data).to.have.property('id');
+ expect(data).to.have.property('locations');
+ expect(data.locations).to.have.length(1);
+ expect(data.locations[0]).to.have.property('path', '/api');
+ expect(data.locations[0]).to.have.property('upstream_host_id', upstreamHostId);
+ });
+ });
+
+ it('Should create a proxy host with mixed locations (direct + upstream)', () => {
+ cy.task('backendApiPost', {
+ token: token,
+ path: '/api/nginx/proxy-hosts',
+ data: {
+ domain_names: ['mixed-locations.example.com'],
+ forward_scheme: 'http',
+ forward_host: '127.0.0.1',
+ forward_port: 80,
+ access_list_id: '0',
+ certificate_id: 0,
+ meta: {},
+ advanced_config: '',
+ locations: [
+ {
+ path: '/api',
+ forward_scheme: 'http',
+ forward_host: '10.0.0.70',
+ forward_port: 3000,
+ },
+ {
+ path: '/ws',
+ forward_scheme: 'http',
+ forward_host: '127.0.0.1',
+ forward_port: 80,
+ upstream_host_id: upstreamHostId,
+ upstream_host_forward_scheme: 'http',
+ }
+ ],
+ block_exploits: false,
+ caching_enabled: false,
+ allow_websocket_upgrade: false,
+ http2_support: false,
+ hsts_enabled: false,
+ hsts_subdomains: false,
+ ssl_forced: false,
+ }
+ }).then((data) => {
+ cy.validateSwaggerSchema('post', 201, '/nginx/proxy-hosts', data);
+ expect(data.locations).to.have.length(2);
+ expect(data.locations[0]).to.have.property('path', '/api');
+ expect(data.locations[0]).to.have.property('forward_host', '10.0.0.70');
+ expect(data.locations[1]).to.have.property('path', '/ws');
+ expect(data.locations[1]).to.have.property('upstream_host_id', upstreamHostId);
+ });
+ });
+});