mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-09-24 13:50:20 +01:00
Adds integration tests for per path access lists, fixes ipv6 jsv,
and enforces host-wide access list when location access list is not set
This commit is contained in:
@@ -42,9 +42,11 @@ const getProxyHostsUsingAccessListInLocations = async (accessListId) => {
|
|||||||
[`%"access_list_id":${accessListId}%`],
|
[`%"access_list_id":${accessListId}%`],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// knex raw() returns [rows, metadata] for MySQL
|
// knex raw() returns [rows, metadata] for MySQL, { rows } for Postgres and rows for SQLite
|
||||||
const rows = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result;
|
if (!Array.isArray(result)) {
|
||||||
return rows || [];
|
return result?.rows || [];
|
||||||
|
}
|
||||||
|
return (Array.isArray(result[0]) ? result[0] : result) || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const internalAccessList = {
|
const internalAccessList = {
|
||||||
@@ -233,26 +235,6 @@ const internalAccessList = {
|
|||||||
.whereIn("id", locationHostIds)
|
.whereIn("id", locationHostIds)
|
||||||
.allowGraph(proxyHostModel.defaultAllowGraph)
|
.allowGraph(proxyHostModel.defaultAllowGraph)
|
||||||
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
|
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
|
||||||
for (const host of locationHosts) {
|
|
||||||
// Fetch access lists for locations
|
|
||||||
if (host.locations?.length) {
|
|
||||||
for (let i = 0; i < host.locations.length; i++) {
|
|
||||||
const loc = host.locations[i];
|
|
||||||
if (loc.access_list_id && loc.access_list_id > 0) {
|
|
||||||
const locAccessList = await accessListModel
|
|
||||||
.query()
|
|
||||||
.allowGraph("[clients,items]")
|
|
||||||
.where("is_deleted", 0)
|
|
||||||
.andWhere("id", loc.access_list_id)
|
|
||||||
.withGraphFetched("[clients,items]")
|
|
||||||
.first();
|
|
||||||
if (locAccessList) {
|
|
||||||
host.locations[i].access_list = locAccessList;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await internalNginx.bulkGenerateConfigs("proxy_host", locationHosts);
|
await internalNginx.bulkGenerateConfigs("proxy_host", locationHosts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,50 +320,38 @@ const internalAccessList = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 2. update any proxy hosts that were using it (ignoring permissions)
|
// 2. update any proxy hosts that were using it (ignoring permissions)
|
||||||
if (row.proxy_hosts) {
|
const affectedHostIds = new Set((row.proxy_hosts || []).map((h) => h.id));
|
||||||
|
if (affectedHostIds.size) {
|
||||||
await proxyHostModel.query().where("access_list_id", "=", row.id).patch({ access_list_id: 0 });
|
await proxyHostModel.query().where("access_list_id", "=", row.id).patch({ access_list_id: 0 });
|
||||||
|
|
||||||
// 3. reconfigure those hosts, then reload nginx
|
|
||||||
// set the access_list_id to zero for these items
|
|
||||||
row.proxy_hosts.map((_val, idx) => {
|
|
||||||
row.proxy_hosts[idx].access_list_id = 0;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
await internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also handle proxy hosts that reference this access list in their locations JSON
|
// Also clear it from any proxy host locations using it, these will then inherit the host's access list
|
||||||
const locationHostRows = await getProxyHostsUsingAccessListInLocations(row.id);
|
const locationHostRows = await getProxyHostsUsingAccessListInLocations(row.id);
|
||||||
if (locationHostRows?.length) {
|
for (const { id: hostId } of locationHostRows) {
|
||||||
const locationHostIds = locationHostRows.map((r) => r.id).filter((id) => {
|
const host = await proxyHostModel.query().where("id", hostId).first();
|
||||||
return !row.proxy_hosts?.find((h) => h.id === id);
|
if (host?.locations?.some((loc) => loc.access_list_id === row.id)) {
|
||||||
});
|
const updatedLocations = host.locations.map((loc) => {
|
||||||
if (locationHostIds.length) {
|
if (loc.access_list_id === row.id) {
|
||||||
// Clear the access_list_id in locations JSON for these hosts
|
return { ...loc, access_list_id: 0 };
|
||||||
for (const hostId of locationHostIds) {
|
|
||||||
const host = await proxyHostModel.query().where("id", hostId).first();
|
|
||||||
if (host?.locations) {
|
|
||||||
const updatedLocations = host.locations.map((loc) => {
|
|
||||||
if (loc.access_list_id === row.id) {
|
|
||||||
return { ...loc, access_list_id: 0 };
|
|
||||||
}
|
|
||||||
return loc;
|
|
||||||
});
|
|
||||||
await proxyHostModel.query().where("id", hostId).patch({ locations: updatedLocations });
|
|
||||||
}
|
}
|
||||||
}
|
return loc;
|
||||||
|
});
|
||||||
// Re-fetch and regenerate configs
|
await proxyHostModel.query().where("id", hostId).patch({ locations: updatedLocations });
|
||||||
const locationHosts = await proxyHostModel.query()
|
affectedHostIds.add(hostId);
|
||||||
.where("is_deleted", 0)
|
|
||||||
.whereIn("id", locationHostIds)
|
|
||||||
.allowGraph(proxyHostModel.defaultExpand)
|
|
||||||
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
|
|
||||||
await internalNginx.bulkGenerateConfigs("proxy_host", locationHosts);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. reconfigure those hosts from fresh rows, then reload nginx
|
||||||
|
if (affectedHostIds.size) {
|
||||||
|
const affectedHosts = await proxyHostModel
|
||||||
|
.query()
|
||||||
|
.where("is_deleted", 0)
|
||||||
|
.whereIn("id", [...affectedHostIds])
|
||||||
|
.allowGraph(proxyHostModel.defaultAllowGraph)
|
||||||
|
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
|
||||||
|
await internalNginx.bulkGenerateConfigs("proxy_host", affectedHosts);
|
||||||
|
}
|
||||||
|
|
||||||
await internalNginx.reload();
|
await internalNginx.reload();
|
||||||
|
|
||||||
// delete the htpasswd file
|
// delete the htpasswd file
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import _ from "lodash";
|
|||||||
import errs from "../lib/error.js";
|
import errs from "../lib/error.js";
|
||||||
import utils from "../lib/utils.js";
|
import utils from "../lib/utils.js";
|
||||||
import { debug, nginx as logger } from "../logger.js";
|
import { debug, nginx as logger } from "../logger.js";
|
||||||
|
import accessListModel from "../models/access_list.js";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
@@ -168,6 +169,24 @@ const internalNginx = {
|
|||||||
host.locations[i],
|
host.locations[i],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A location with its own access list overrides the host's,
|
||||||
|
// otherwise it inherits the host's access list
|
||||||
|
let locationAccessList = null;
|
||||||
|
if (locationCopy.access_list_id > 0 && locationCopy.access_list_id !== host.access_list?.id) {
|
||||||
|
locationAccessList = await accessListModel
|
||||||
|
.query()
|
||||||
|
.where("is_deleted", 0)
|
||||||
|
.andWhere("id", locationCopy.access_list_id)
|
||||||
|
.withGraphFetched("[clients,items]")
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
if (locationAccessList) {
|
||||||
|
locationCopy.access_list = locationAccessList;
|
||||||
|
} else {
|
||||||
|
locationCopy.access_list_id = host.access_list_id;
|
||||||
|
locationCopy.access_list = host.access_list;
|
||||||
|
}
|
||||||
|
|
||||||
if (locationCopy.forward_host.indexOf("/") > -1) {
|
if (locationCopy.forward_host.indexOf("/") > -1) {
|
||||||
const splitted = locationCopy.forward_host.split("/");
|
const splitted = locationCopy.forward_host.split("/");
|
||||||
|
|
||||||
@@ -179,7 +198,9 @@ const internalNginx = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
locationRendering().then(() => resolve(renderedLocations));
|
locationRendering()
|
||||||
|
.then(() => resolve(renderedLocations))
|
||||||
|
.catch(reject);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -271,6 +292,9 @@ const internalNginx = {
|
|||||||
debug(logger, `Could not write ${filename}:`, err.message);
|
debug(logger, `Could not write ${filename}:`, err.message);
|
||||||
reject(new errs.ConfigurationError(err.message));
|
reject(new errs.ConfigurationError(err.message));
|
||||||
});
|
});
|
||||||
|
}).catch((err) => {
|
||||||
|
debug(logger, `Could not render locations for ${filename}:`, err.message);
|
||||||
|
reject(new errs.ConfigurationError(err.message));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import _ from "lodash";
|
|||||||
import errs from "../lib/error.js";
|
import errs from "../lib/error.js";
|
||||||
import { castJsonIfNeed } from "../lib/helpers.js";
|
import { castJsonIfNeed } from "../lib/helpers.js";
|
||||||
import utils from "../lib/utils.js";
|
import utils from "../lib/utils.js";
|
||||||
import accessListModel from "../models/access_list.js";
|
|
||||||
import proxyHostModel from "../models/proxy_host.js";
|
import proxyHostModel from "../models/proxy_host.js";
|
||||||
import internalAuditLog from "./audit-log.js";
|
import internalAuditLog from "./audit-log.js";
|
||||||
import internalCertificate from "./certificate.js";
|
import internalCertificate from "./certificate.js";
|
||||||
@@ -13,33 +12,6 @@ const omissions = () => {
|
|||||||
return ["is_deleted", "owner.is_deleted"];
|
return ["is_deleted", "owner.is_deleted"];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches access lists for each location that has its own access_list_id.
|
|
||||||
* Attaches the expanded access_list object (with clients and items) to each location.
|
|
||||||
*
|
|
||||||
* @param {Object} host
|
|
||||||
* @returns {Promise}
|
|
||||||
*/
|
|
||||||
const fetchLocationAccessLists = async (host) => {
|
|
||||||
if (!host.locations?.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < host.locations.length; i++) {
|
|
||||||
const loc = host.locations[i];
|
|
||||||
if (loc.access_list_id && loc.access_list_id > 0) {
|
|
||||||
const accessList = await accessListModel
|
|
||||||
.query()
|
|
||||||
.where("is_deleted", 0)
|
|
||||||
.andWhere("id", loc.access_list_id)
|
|
||||||
.withGraphFetched("[clients,items]")
|
|
||||||
.first();
|
|
||||||
if (accessList) {
|
|
||||||
host.locations[i].access_list = accessList;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const internalProxyHost = {
|
const internalProxyHost = {
|
||||||
/**
|
/**
|
||||||
* @param {Access} access
|
* @param {Access} access
|
||||||
@@ -112,7 +84,6 @@ const internalProxyHost = {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.then(async (row) => {
|
.then(async (row) => {
|
||||||
await fetchLocationAccessLists(row);
|
|
||||||
// Configure nginx
|
// Configure nginx
|
||||||
return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => {
|
return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => {
|
||||||
return row;
|
return row;
|
||||||
@@ -242,7 +213,6 @@ const internalProxyHost = {
|
|||||||
// No need to add nginx config if host is disabled
|
// No need to add nginx config if host is disabled
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
await fetchLocationAccessLists(row);
|
|
||||||
// Configure nginx
|
// Configure nginx
|
||||||
return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => {
|
return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => {
|
||||||
row.meta = new_meta;
|
row.meta = new_meta;
|
||||||
@@ -373,8 +343,6 @@ const internalProxyHost = {
|
|||||||
enabled: 1,
|
enabled: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
await fetchLocationAccessLists(row);
|
|
||||||
|
|
||||||
// Configure nginx
|
// Configure nginx
|
||||||
await internalNginx.configure(proxyHostModel, "proxy_host", row);
|
await internalNginx.configure(proxyHostModel, "proxy_host", row);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import net from "node:net";
|
||||||
import Ajv from "ajv/dist/2020.js";
|
import Ajv from "ajv/dist/2020.js";
|
||||||
import errs from "../error.js";
|
import errs from "../error.js";
|
||||||
|
|
||||||
@@ -9,6 +10,8 @@ const ajv = new Ajv({
|
|||||||
coerceTypes: true,
|
coerceTypes: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} schema
|
* @param {Object} schema
|
||||||
* @param {Object} payload
|
* @param {Object} payload
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import net from "node:net";
|
||||||
import Ajv from "ajv/dist/2020.js";
|
import Ajv from "ajv/dist/2020.js";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import commonDefinitions from "../../schema/common.json" with { type: "json" };
|
import commonDefinitions from "../../schema/common.json" with { type: "json" };
|
||||||
@@ -14,6 +15,8 @@ const ajv = new Ajv({
|
|||||||
schemas: [commonDefinitions],
|
schemas: [commonDefinitions],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {Object} schema
|
* @param {Object} schema
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ class AccessList extends Model {
|
|||||||
|
|
||||||
$parseDatabaseJson(json) {
|
$parseDatabaseJson(json) {
|
||||||
const thisJson = super.$parseDatabaseJson(json);
|
const thisJson = super.$parseDatabaseJson(json);
|
||||||
|
// Postgres returns COUNT() as a string
|
||||||
|
if (typeof thisJson.proxy_host_count === "string") {
|
||||||
|
thisJson.proxy_host_count = Number.parseInt(thisJson.proxy_host_count, 10);
|
||||||
|
}
|
||||||
return convertIntFieldsToBool(thisJson, boolFields);
|
return convertIntFieldsToBool(thisJson, boolFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$"
|
"pattern": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ services:
|
|||||||
- website2.example.com
|
- website2.example.com
|
||||||
- website3.example.com
|
- website3.example.com
|
||||||
|
|
||||||
|
examplesite:
|
||||||
|
image: "${IMAGE}-examplesite:ci-${BUILD_NUMBER}"
|
||||||
|
build:
|
||||||
|
context: ../test/docker
|
||||||
|
dockerfile: Dockerfile.website
|
||||||
|
expose:
|
||||||
|
- "80/tcp"
|
||||||
|
networks:
|
||||||
|
- fulltest
|
||||||
|
|
||||||
stepca:
|
stepca:
|
||||||
image: nginxproxymanager/testca
|
image: nginxproxymanager/testca
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IconLock, IconLockOpen2 } from "@tabler/icons-react";
|
import { IconArrowBackUp, IconLock, IconLockOpen2 } from "@tabler/icons-react";
|
||||||
import { Field, useFormikContext } from "formik";
|
import { Field, useFormikContext } from "formik";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import Select, { type ActionMeta, components, type OptionProps } from "react-select";
|
import Select, { type ActionMeta, components, type OptionProps } from "react-select";
|
||||||
@@ -32,8 +32,16 @@ interface Props {
|
|||||||
name?: string;
|
name?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
onFormChange?: (value: number) => void;
|
onFormChange?: (value: number) => void;
|
||||||
|
// When set, the 0 option inherits the host's access list instead of being public
|
||||||
|
inheritHost?: boolean;
|
||||||
}
|
}
|
||||||
export function AccessField({ name = "accessListId", label = "access-list", id = "accessListId", onFormChange }: Props) {
|
export function AccessField({
|
||||||
|
name = "accessListId",
|
||||||
|
label = "access-list",
|
||||||
|
id = "accessListId",
|
||||||
|
onFormChange,
|
||||||
|
inheritHost = false,
|
||||||
|
}: Props) {
|
||||||
const { locale } = useLocaleState();
|
const { locale } = useLocaleState();
|
||||||
const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]);
|
const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]);
|
||||||
const { setFieldValue } = useFormikContext();
|
const { setFieldValue } = useFormikContext();
|
||||||
@@ -60,13 +68,22 @@ export function AccessField({ name = "accessListId", label = "access-list", id =
|
|||||||
icon: <IconLock size={14} className="text-lime" />,
|
icon: <IconLock size={14} className="text-lime" />,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
// Public option
|
// Public or inherit option
|
||||||
options?.unshift({
|
options?.unshift(
|
||||||
value: 0,
|
inheritHost
|
||||||
label: intl.formatMessage({ id: "access-list.public" }),
|
? {
|
||||||
subLabel: intl.formatMessage({ id: "access-list.public.subtitle" }),
|
value: 0,
|
||||||
icon: <IconLockOpen2 size={14} className="text-red" />,
|
label: intl.formatMessage({ id: "access-list.inherit" }),
|
||||||
});
|
subLabel: intl.formatMessage({ id: "access-list.inherit.subtitle" }),
|
||||||
|
icon: <IconArrowBackUp size={14} className="text-secondary" />,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
value: 0,
|
||||||
|
label: intl.formatMessage({ id: "access-list.public" }),
|
||||||
|
subLabel: intl.formatMessage({ id: "access-list.public.subtitle" }),
|
||||||
|
icon: <IconLockOpen2 size={14} className="text-red" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Field name={name}>
|
<Field name={name}>
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
|||||||
label="access-list"
|
label="access-list"
|
||||||
id={`locations-access-list-${row.id}`}
|
id={`locations-access-list-${row.id}`}
|
||||||
onFormChange={(value) => handleAccessListChange(row.id, value)}
|
onFormChange={(value) => handleAccessListChange(row.id, value)}
|
||||||
|
inheritHost
|
||||||
/>
|
/>
|
||||||
{advVisible.includes(row.id) && (
|
{advVisible.includes(row.id) && (
|
||||||
<div className="">
|
<div className="">
|
||||||
|
|||||||
@@ -71,6 +71,12 @@
|
|||||||
"access-list.help.rules-order": {
|
"access-list.help.rules-order": {
|
||||||
"defaultMessage": "Note that the allow and deny directives will be applied in the order they are defined."
|
"defaultMessage": "Note that the allow and deny directives will be applied in the order they are defined."
|
||||||
},
|
},
|
||||||
|
"access-list.inherit": {
|
||||||
|
"defaultMessage": "Inherit from Proxy Host"
|
||||||
|
},
|
||||||
|
"access-list.inherit.subtitle": {
|
||||||
|
"defaultMessage": "Uses the proxy host's access list"
|
||||||
|
},
|
||||||
"access-list.pass-auth": {
|
"access-list.pass-auth": {
|
||||||
"defaultMessage": "Pass Auth to Upstream"
|
"defaultMessage": "Pass Auth to Upstream"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -71,6 +71,12 @@
|
|||||||
"access-list.help.rules-order": {
|
"access-list.help.rules-order": {
|
||||||
"defaultMessage": "Luba ja keela reeglid rakenduvad selles järjekorras, milles need on kirjas."
|
"defaultMessage": "Luba ja keela reeglid rakenduvad selles järjekorras, milles need on kirjas."
|
||||||
},
|
},
|
||||||
|
"access-list.inherit": {
|
||||||
|
"defaultMessage": "Päri puhverserverilt"
|
||||||
|
},
|
||||||
|
"access-list.inherit.subtitle": {
|
||||||
|
"defaultMessage": "Kasutab puhverserveri juurdepääsuloendit"
|
||||||
|
},
|
||||||
"access-list.pass-auth": {
|
"access-list.pass-auth": {
|
||||||
"defaultMessage": "Edasta autentimine sihtserverile"
|
"defaultMessage": "Edasta autentimine sihtserverile"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ rm -rf "${LOCAL_RESOLVE}"
|
|||||||
printf "nameserver %s\noptions ndots:0" "${DNSROUTER_IP}" > "${LOCAL_RESOLVE}"
|
printf "nameserver %s\noptions ndots:0" "${DNSROUTER_IP}" > "${LOCAL_RESOLVE}"
|
||||||
|
|
||||||
# bring up all remaining containers, except cypress!
|
# bring up all remaining containers, except cypress!
|
||||||
docker compose up -d --remove-orphans stepca squid
|
docker compose build examplesite
|
||||||
|
docker compose up -d --remove-orphans --no-build stepca squid examplesite
|
||||||
docker compose pull db-mysql || true # ok to fail
|
docker compose pull db-mysql || true # ok to fail
|
||||||
docker compose pull db-postgres || true # ok to fail
|
docker compose pull db-postgres || true # ok to fail
|
||||||
docker compose pull authentik authentik-redis authentik-ldap || true # ok to fail
|
docker compose pull authentik authentik-redis authentik-ldap || true # ok to fail
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
|
describe('Per-path Access Lists', () => {
|
||||||
|
const domain = 'website3.example.com';
|
||||||
|
|
||||||
|
const alpha = {
|
||||||
|
name: 'Path Alpha',
|
||||||
|
username: 'alpha-user',
|
||||||
|
password: 'alpha-pass',
|
||||||
|
};
|
||||||
|
|
||||||
|
const beta = {
|
||||||
|
name: 'Path Beta',
|
||||||
|
username: 'beta-user',
|
||||||
|
password: 'beta-pass',
|
||||||
|
};
|
||||||
|
|
||||||
|
let token;
|
||||||
|
let alphaListId;
|
||||||
|
let betaListId;
|
||||||
|
let hostId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests a path on the proxy host directly (bypassing squid)
|
||||||
|
* and yields the HTTP status code and response body
|
||||||
|
*
|
||||||
|
* @param {string} path
|
||||||
|
* @param {object} [creds]
|
||||||
|
* @param {string} creds.username
|
||||||
|
* @param {string} creds.password
|
||||||
|
*/
|
||||||
|
const request = (path, creds) => {
|
||||||
|
const auth = creds ? `-u '${creds.username}:${creds.password}'` : '';
|
||||||
|
return cy.exec(`curl --noproxy '*' -s -w '\n%{http_code}' ${auth} http://${domain}${path}`)
|
||||||
|
.then((result) => {
|
||||||
|
expect(result.exitCode).to.eq(0);
|
||||||
|
const lines = result.stdout.trim().split('\n');
|
||||||
|
const status = lines.pop();
|
||||||
|
return { status: status, body: lines.join('\n') };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// nginx reloads are signalled and return immediately, so the old
|
||||||
|
// config can still be served for a moment after an API change.
|
||||||
|
// Retry until the expected response is seen.
|
||||||
|
const waitForResponse = (path, creds, check, description) => {
|
||||||
|
// Copy now, the callback runs later and creds may have been changed by then
|
||||||
|
const credsCopy = creds ? { ...creds } : null;
|
||||||
|
let last = null;
|
||||||
|
cy.waitUntil(() => request(path, credsCopy).then((res) => {
|
||||||
|
last = res;
|
||||||
|
return check(res);
|
||||||
|
}), {
|
||||||
|
timeout: 15000,
|
||||||
|
interval: 500,
|
||||||
|
errorMsg: () => `${path} did not return ${description}, last status: ${last?.status}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectStatus = (path, creds, status) => {
|
||||||
|
waitForResponse(path, creds, (res) => res.status === status, status);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Also checks the body so we know the page came from examplesite
|
||||||
|
// and not the NPM default site
|
||||||
|
const expectPage = (path, creds, text) => {
|
||||||
|
waitForResponse(path, creds, (res) => res.status === '200' && res.body.includes(text), `200 with "${text}"`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAccessList = (list) => {
|
||||||
|
return cy.task('backendApiPost', {
|
||||||
|
token: token,
|
||||||
|
path: '/api/nginx/access-lists',
|
||||||
|
data: {
|
||||||
|
name: list.name,
|
||||||
|
satisfy_any: false,
|
||||||
|
pass_auth: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
username: list.username,
|
||||||
|
password: list.password,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
clients: [],
|
||||||
|
}
|
||||||
|
}).then((data) => {
|
||||||
|
cy.validateSwaggerSchema('post', 201, '/nginx/access-lists', data);
|
||||||
|
expect(data).to.have.property('id');
|
||||||
|
expect(data.id).to.be.greaterThan(0);
|
||||||
|
return cy.wrap(data.id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const location = (path, accessListId) => {
|
||||||
|
return {
|
||||||
|
path: path,
|
||||||
|
forward_scheme: 'http',
|
||||||
|
forward_host: 'examplesite',
|
||||||
|
forward_port: 80,
|
||||||
|
access_list_id: accessListId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
cy.resetUsers();
|
||||||
|
cy.getToken().then((tok) => {
|
||||||
|
token = tok;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should be able to create multiple access lists with credentials', () => {
|
||||||
|
createAccessList(alpha).then((id) => {
|
||||||
|
alphaListId = id;
|
||||||
|
});
|
||||||
|
createAccessList(beta).then((id) => {
|
||||||
|
betaListId = id;
|
||||||
|
expect(betaListId).to.not.equal(alphaListId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should be able to create a proxy host with a different access list per location', () => {
|
||||||
|
cy.task('backendApiPost', {
|
||||||
|
token: token,
|
||||||
|
path: '/api/nginx/proxy-hosts',
|
||||||
|
data: {
|
||||||
|
domain_names: [domain],
|
||||||
|
forward_scheme: 'http',
|
||||||
|
forward_host: 'examplesite',
|
||||||
|
forward_port: 80,
|
||||||
|
access_list_id: 0,
|
||||||
|
certificate_id: 0,
|
||||||
|
meta: {
|
||||||
|
dns_challenge: false
|
||||||
|
},
|
||||||
|
advanced_config: '',
|
||||||
|
locations: [
|
||||||
|
location('/dashboard', alphaListId),
|
||||||
|
location('/profile', betaListId),
|
||||||
|
],
|
||||||
|
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);
|
||||||
|
hostId = data.id;
|
||||||
|
expect(data).to.have.property('enabled', true);
|
||||||
|
expect(data).to.have.property('access_list_id', 0);
|
||||||
|
expect(data.locations).to.have.length(2);
|
||||||
|
expect(data.locations[0]).to.have.property('access_list_id', alphaListId);
|
||||||
|
expect(data.locations[1]).to.have.property('access_list_id', betaListId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should persist the location access lists on the proxy host', () => {
|
||||||
|
cy.task('backendApiGet', {
|
||||||
|
token: token,
|
||||||
|
path: `/api/nginx/proxy-hosts/${hostId}`,
|
||||||
|
}).then((data) => {
|
||||||
|
cy.validateSwaggerSchema('get', 200, '/nginx/proxy-hosts/{hostID}', data);
|
||||||
|
expect(data.locations[0]).to.have.property('path', '/dashboard');
|
||||||
|
expect(data.locations[0]).to.have.property('access_list_id', alphaListId);
|
||||||
|
expect(data.locations[1]).to.have.property('path', '/profile');
|
||||||
|
expect(data.locations[1]).to.have.property('access_list_id', betaListId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should not require auth for the host root', () => {
|
||||||
|
expectPage('/', null, 'this is the index page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should only accept the alpha credentials on /dashboard', () => {
|
||||||
|
expectStatus('/dashboard', null, '401');
|
||||||
|
expectStatus('/dashboard', beta, '401');
|
||||||
|
expectPage('/dashboard', alpha, 'this is the dashboard page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should only accept the beta credentials on /profile', () => {
|
||||||
|
expectStatus('/profile', null, '401');
|
||||||
|
expectStatus('/profile', alpha, '401');
|
||||||
|
expectPage('/profile', beta, 'this is the profile page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should apply access list credential changes to locations using it', () => {
|
||||||
|
const newPassword = 'alpha-pass-changed';
|
||||||
|
|
||||||
|
cy.task('backendApiPut', {
|
||||||
|
token: token,
|
||||||
|
path: `/api/nginx/access-lists/${alphaListId}`,
|
||||||
|
data: {
|
||||||
|
name: alpha.name,
|
||||||
|
satisfy_any: false,
|
||||||
|
pass_auth: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
username: alpha.username,
|
||||||
|
password: newPassword,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
clients: [],
|
||||||
|
}
|
||||||
|
}).then((data) => {
|
||||||
|
cy.validateSwaggerSchema('put', 200, '/nginx/access-lists/{listID}', data);
|
||||||
|
expect(data).to.have.property('id', alphaListId);
|
||||||
|
});
|
||||||
|
|
||||||
|
expectStatus('/dashboard', alpha, '401');
|
||||||
|
expectPage('/dashboard', { username: alpha.username, password: newPassword }, 'this is the dashboard page');
|
||||||
|
alpha.password = newPassword;
|
||||||
|
|
||||||
|
// Other locations are unaffected
|
||||||
|
expectStatus('/profile', null, '401');
|
||||||
|
expectPage('/profile', beta, 'this is the profile page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should inherit the host access list on locations without their own', () => {
|
||||||
|
// Host uses beta, /dashboard overrides with alpha, /missing has none and should inherit beta
|
||||||
|
cy.task('backendApiPut', {
|
||||||
|
token: token,
|
||||||
|
path: `/api/nginx/proxy-hosts/${hostId}`,
|
||||||
|
data: {
|
||||||
|
access_list_id: betaListId,
|
||||||
|
locations: [
|
||||||
|
location('/dashboard', alphaListId),
|
||||||
|
location('/profile', betaListId),
|
||||||
|
location('/missing', 0),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}).then((data) => {
|
||||||
|
// No swagger validation here: with a host access list set, the expanded
|
||||||
|
// access_list in the response has no proxy_host_count, which the schema requires
|
||||||
|
expect(data).to.have.property('access_list_id', betaListId);
|
||||||
|
expect(data.locations[2]).to.have.property('access_list_id', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
expectStatus('/', null, '401');
|
||||||
|
expectStatus('/', alpha, '401');
|
||||||
|
expectPage('/', beta, 'this is the index page');
|
||||||
|
|
||||||
|
// examplesite returns 404 for this path, once past the access list
|
||||||
|
expectStatus('/missing', null, '401');
|
||||||
|
expectStatus('/missing', alpha, '401');
|
||||||
|
expectStatus('/missing', beta, '404');
|
||||||
|
|
||||||
|
expectStatus('/dashboard', beta, '401');
|
||||||
|
expectPage('/dashboard', alpha, 'this is the dashboard page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should remove a deleted access list from the host and locations using it', () => {
|
||||||
|
cy.task('backendApiDelete', {
|
||||||
|
token: token,
|
||||||
|
path: `/api/nginx/access-lists/${betaListId}`,
|
||||||
|
}).then((data) => {
|
||||||
|
cy.validateSwaggerSchema('delete', 200, '/nginx/access-lists/{listID}', data);
|
||||||
|
expect(data).to.be.equal(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.task('backendApiGet', {
|
||||||
|
token: token,
|
||||||
|
path: `/api/nginx/proxy-hosts/${hostId}`,
|
||||||
|
}).then((data) => {
|
||||||
|
expect(data).to.have.property('access_list_id', 0);
|
||||||
|
expect(data.locations[0]).to.have.property('access_list_id', alphaListId);
|
||||||
|
expect(data.locations[1]).to.have.property('access_list_id', 0);
|
||||||
|
expect(data.locations[2]).to.have.property('access_list_id', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
expectPage('/', null, 'this is the index page');
|
||||||
|
expectPage('/profile', null, 'this is the profile page');
|
||||||
|
expectStatus('/missing', null, '404');
|
||||||
|
|
||||||
|
// Remaining location access list must still be enforced
|
||||||
|
expectStatus('/dashboard', null, '401');
|
||||||
|
expectPage('/dashboard', alpha, 'this is the dashboard page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should be able to delete the proxy host and remaining access list', () => {
|
||||||
|
cy.task('backendApiDelete', {
|
||||||
|
token: token,
|
||||||
|
path: `/api/nginx/proxy-hosts/${hostId}`,
|
||||||
|
}).then((data) => {
|
||||||
|
cy.validateSwaggerSchema('delete', 200, '/nginx/proxy-hosts/{hostID}', data);
|
||||||
|
expect(data).to.be.equal(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.task('backendApiDelete', {
|
||||||
|
token: token,
|
||||||
|
path: `/api/nginx/access-lists/${alphaListId}`,
|
||||||
|
}).then((data) => {
|
||||||
|
cy.validateSwaggerSchema('delete', 200, '/nginx/access-lists/{listID}', data);
|
||||||
|
expect(data).to.be.equal(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
FROM nginx:stable
|
||||||
|
|
||||||
|
RUN rm -rf /etc/nginx/conf.d
|
||||||
|
COPY nginx /etc/nginx
|
||||||
|
COPY www /www
|
||||||
|
RUN chown -R nginx:nginx /www
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name website1.example.com website2.example.com website3.example.com;
|
||||||
|
root /www;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# redirect requests for .html URLs to their extensionless form
|
||||||
|
if ($request_uri ~ ^/index\.html(\?.*)?$) {
|
||||||
|
return 301 /$1;
|
||||||
|
}
|
||||||
|
if ($request_uri ~ ^/(.+)\.html(\?.*)?$) {
|
||||||
|
return 301 /$1$2;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri.html $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 404 /404.html;
|
||||||
|
location = /404.html {
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
# deny access to .htaccess files, if Apache's document root
|
||||||
|
# concurs with nginx's one
|
||||||
|
location ~ /\.ht {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log notice;
|
||||||
|
pid /run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
log_format custom_combined '$remote_addr - $remote_user [$time_local] "$host" "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log custom_combined;
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
gzip on;
|
||||||
|
|
||||||
|
set_real_ip_from 10.0.0.0/8;
|
||||||
|
set_real_ip_from 172.16.0.0/12; # Includes Docker subnet
|
||||||
|
set_real_ip_from 192.168.0.0/16;
|
||||||
|
|
||||||
|
include include/*.conf;
|
||||||
|
include conf.d/*.conf;
|
||||||
|
include /sites/*/nginx.conf;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>404</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
404 not found
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
this is the dashboard page
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Index</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
this is the index page
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Profile</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
this is the profile page
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
Reference in New Issue
Block a user