diff --git a/backend/internal/access-list.js b/backend/internal/access-list.js index 60a7105dbe..6a1000bb48 100644 --- a/backend/internal/access-list.js +++ b/backend/internal/access-list.js @@ -8,6 +8,7 @@ import accessListModel from "../models/access_list.js"; import accessListAuthModel from "../models/access_list_auth.js"; import accessListClientModel from "../models/access_list_client.js"; import proxyHostModel from "../models/proxy_host.js"; +import streamModel from "../models/stream.js"; import internalAuditLog from "./audit-log.js"; import internalNginx from "./nginx.js"; @@ -189,7 +190,7 @@ const internalAccessList = { access, { id: data.id, - expand: ["owner", "items", "clients", "proxy_hosts.[certificate,access_list.[clients,items]]"], + expand: ["owner", "items", "clients", "proxy_hosts.[certificate,access_list.[clients,items]]", "streams"], }, true // skip masking ); @@ -198,6 +199,10 @@ const internalAccessList = { if (Number.parseInt(freshRow.proxy_host_count, 10)) { await internalNginx.bulkGenerateConfigs("proxy_host", freshRow.proxy_hosts); } + // Also regenerate stream configs if any streams use this access list + if (freshRow.streams && freshRow.streams.length > 0) { + await internalNginx.bulkGenerateConfigs("stream", freshRow.streams); + } await internalNginx.reload(); return internalAccessList.maskItems(freshRow); }, @@ -228,7 +233,7 @@ const internalAccessList = { .where("access_list.is_deleted", 0) .andWhere("access_list.id", thisData.id) .groupBy("access_list.id") - .allowGraph("[owner,items,clients,proxy_hosts.[certificate,access_list.[clients,items]]]") + .allowGraph("[owner,items,clients,proxy_hosts.[certificate,access_list.[clients,items]],streams]") .first(); if (accessData.permission_visibility !== "all") { @@ -265,7 +270,7 @@ const internalAccessList = { await access.can("access_lists:delete", data.id); const row = await internalAccessList.get(access, { id: data.id, - expand: ["proxy_hosts", "items", "clients"], + expand: ["proxy_hosts", "streams", "items", "clients"], }); if (!row || !row.id) { @@ -273,8 +278,8 @@ const internalAccessList = { } // 1. update row to be deleted - // 2. update any proxy hosts that were using it (ignoring permissions) - // 3. reconfigure those hosts + // 2. update any proxy hosts and streams that were using it (ignoring permissions) + // 3. reconfigure those hosts and streams // 4. audit log // 1. update row to be deleted @@ -302,6 +307,23 @@ const internalAccessList = { await internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts); } + // 2b. update any streams that were using it (ignoring permissions) + if (row.streams) { + await streamModel + .query() + .where("access_list_id", "=", row.id) + .patch({ access_list_id: 0 }); + + // 3b. reconfigure those streams + // set the access_list_id to zero for these items + row.streams.map((_val, idx) => { + row.streams[idx].access_list_id = 0; + return true; + }); + + await internalNginx.bulkGenerateConfigs("stream", row.streams); + } + await internalNginx.reload(); // delete the htpasswd file diff --git a/backend/internal/stream.js b/backend/internal/stream.js index 805b6652a1..25211c009b 100644 --- a/backend/internal/stream.js +++ b/backend/internal/stream.js @@ -9,7 +9,7 @@ import internalHost from "./host.js"; import internalNginx from "./nginx.js"; const omissions = () => { - return ["is_deleted", "owner.is_deleted", "certificate.is_deleted"]; + return ["is_deleted", "owner.is_deleted", "certificate.is_deleted", "access_list.is_deleted"]; }; const internalStream = { @@ -62,7 +62,7 @@ const internalStream = { // re-fetch with cert return internalStream.get(access, { id: row.id, - expand: ["certificate", "owner"], + expand: ["certificate", "owner", "access_list.clients"], }); }) .then((row) => { @@ -159,7 +159,7 @@ const internalStream = { }); }) .then(() => { - return internalStream.get(access, { id: thisData.id, expand: ["owner", "certificate"] }).then((row) => { + return internalStream.get(access, { id: thisData.id, expand: ["owner", "certificate", "access_list.clients"] }).then((row) => { return internalNginx.configure(streamModel, "stream", row).then((new_meta) => { row.meta = new_meta; return _.omit(internalHost.cleanRowCertificateMeta(row), omissions()); @@ -186,7 +186,7 @@ const internalStream = { .query() .where("is_deleted", 0) .andWhere("id", thisData.id) - .allowGraph("[owner,certificate]") + .allowGraph("[owner,certificate,access_list.clients]") .first(); if (access_data.permission_visibility !== "all") { @@ -271,7 +271,7 @@ const internalStream = { .then(() => { return internalStream.get(access, { id: data.id, - expand: ["certificate", "owner"], + expand: ["certificate", "owner", "access_list.clients"], }); }) .then((row) => { @@ -375,7 +375,7 @@ const internalStream = { .query() .where("is_deleted", 0) .groupBy("id") - .allowGraph("[owner,certificate]") + .allowGraph("[owner,certificate,access_list.clients]") .orderBy("incoming_port", "ASC"); if (access_data.permission_visibility !== "all") { diff --git a/backend/migrations/20260123000000_stream_access.js b/backend/migrations/20260123000000_stream_access.js new file mode 100644 index 0000000000..423ffee686 --- /dev/null +++ b/backend/migrations/20260123000000_stream_access.js @@ -0,0 +1,43 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "stream_access"; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @returns {Promise} + */ +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema + .table("stream", (table) => { + table.integer("access_list_id").notNull().unsigned().defaultTo(0); + }) + .then(() => { + logger.info(`[${migrateName}] stream Table altered`); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @returns {Promise} + */ +const down = (knex) => { + logger.info(`[${migrateName}] Migrating Down...`); + + return knex.schema + .table("stream", (table) => { + table.dropColumn("access_list_id"); + }) + .then(() => { + logger.info(`[${migrateName}] stream Table altered`); + }); +}; + +export { up, down }; diff --git a/backend/models/access_list.js b/backend/models/access_list.js index 427d447d62..b5bdfa52c0 100644 --- a/backend/models/access_list.js +++ b/backend/models/access_list.js @@ -8,6 +8,7 @@ import AccessListAuth from "./access_list_auth.js"; import AccessListClient from "./access_list_client.js"; import now from "./now_helper.js"; import ProxyHostModel from "./proxy_host.js"; +import StreamModel from "./stream.js"; import User from "./user.js"; Model.knex(db()); @@ -91,6 +92,17 @@ class AccessList extends Model { qb.where("proxy_host.is_deleted", 0); }, }, + streams: { + relation: Model.HasManyRelation, + modelClass: StreamModel, + join: { + from: "access_list.id", + to: "stream.access_list_id", + }, + modify: (qb) => { + qb.where("stream.is_deleted", 0); + }, + }, }; } } diff --git a/backend/models/stream.js b/backend/models/stream.js index 5f61945a65..b18f0971ab 100644 --- a/backend/models/stream.js +++ b/backend/models/stream.js @@ -1,6 +1,7 @@ import { Model } from "objection"; import db from "../db.js"; import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js"; +import AccessList from "./access_list.js"; import Certificate from "./certificate.js"; import now from "./now_helper.js"; import User from "./user.js"; @@ -70,6 +71,17 @@ class Stream extends Model { qb.where("certificate.is_deleted", 0); }, }, + access_list: { + relation: Model.HasOneRelation, + modelClass: AccessList, + join: { + from: "stream.access_list_id", + to: "access_list.id", + }, + modify: (qb) => { + qb.where("access_list.is_deleted", 0); + }, + }, }; } } diff --git a/backend/schema/components/stream-object.json b/backend/schema/components/stream-object.json index 602073ceca..556187fa43 100644 --- a/backend/schema/components/stream-object.json +++ b/backend/schema/components/stream-object.json @@ -12,6 +12,7 @@ "tcp_forwarding", "udp_forwarding", "enabled", + "access_list_id", "meta" ], "additionalProperties": false, @@ -73,6 +74,20 @@ "certificate_id": { "$ref": "../common.json#/properties/certificate_id" }, + "access_list_id": { + "$ref": "../common.json#/properties/access_list_id" + }, + "access_list": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "./access-list-object.json" + } + ], + "example": null + }, "meta": { "type": "object", "example": {} diff --git a/backend/schema/paths/nginx/streams/get.json b/backend/schema/paths/nginx/streams/get.json index 6dda8e346b..fd89fdb50d 100644 --- a/backend/schema/paths/nginx/streams/get.json +++ b/backend/schema/paths/nginx/streams/get.json @@ -41,7 +41,8 @@ "nginx_err": null }, "enabled": true, - "certificate_id": 0 + "certificate_id": 0, + "access_list_id": 0 } ] } diff --git a/backend/schema/paths/nginx/streams/post.json b/backend/schema/paths/nginx/streams/post.json index 0c986de8fa..c835cdb3a0 100644 --- a/backend/schema/paths/nginx/streams/post.json +++ b/backend/schema/paths/nginx/streams/post.json @@ -41,6 +41,9 @@ "certificate_id": { "$ref": "../../../components/stream-object.json#/properties/certificate_id" }, + "access_list_id": { + "$ref": "../../../components/stream-object.json#/properties/access_list_id" + }, "meta": { "$ref": "../../../components/stream-object.json#/properties/meta" }, @@ -56,6 +59,7 @@ "tcp_forwarding": true, "udp_forwarding": false, "certificate_id": 0, + "access_list_id": 0, "meta": {} } } @@ -96,7 +100,8 @@ "admin" ] }, - "certificate_id": 0 + "certificate_id": 0, + "access_list_id": 0 } } }, diff --git a/backend/schema/paths/nginx/streams/streamID/get.json b/backend/schema/paths/nginx/streams/streamID/get.json index 22fae8872b..8d8f9345ef 100644 --- a/backend/schema/paths/nginx/streams/streamID/get.json +++ b/backend/schema/paths/nginx/streams/streamID/get.json @@ -42,7 +42,8 @@ "nginx_err": null }, "enabled": true, - "certificate_id": 0 + "certificate_id": 0, + "access_list_id": 0 } } }, diff --git a/backend/schema/paths/nginx/streams/streamID/put.json b/backend/schema/paths/nginx/streams/streamID/put.json index 21ae71ef7a..d15f5d3d9e 100644 --- a/backend/schema/paths/nginx/streams/streamID/put.json +++ b/backend/schema/paths/nginx/streams/streamID/put.json @@ -48,6 +48,9 @@ "certificate_id": { "$ref": "../../../../components/stream-object.json#/properties/certificate_id" }, + "access_list_id": { + "$ref": "../../../../components/stream-object.json#/properties/access_list_id" + }, "meta": { "$ref": "../../../../components/stream-object.json#/properties/meta" } @@ -89,7 +92,8 @@ "avatar": "", "roles": ["admin"] }, - "certificate_id": 0 + "certificate_id": 0, + "access_list_id": 0 } } }, diff --git a/backend/templates/_access_stream.conf b/backend/templates/_access_stream.conf new file mode 100644 index 0000000000..8b5596c568 --- /dev/null +++ b/backend/templates/_access_stream.conf @@ -0,0 +1,10 @@ +{% if access_list_id > 0 %} + + # Stream Access Control (IP-based only) + # Note: nginx stream module does not support basic auth or satisfy directives + # Access Rules: {{ access_list.clients | size }} total + {% for client in access_list.clients %} + {{client | nginxAccessRule}} + {% endfor %} + deny all; +{% endif %} diff --git a/backend/templates/stream.conf b/backend/templates/stream.conf index 3a10387b27..8ae0f7aa3c 100644 --- a/backend/templates/stream.conf +++ b/backend/templates/stream.conf @@ -10,6 +10,8 @@ server { {%- include "_certificates_stream.conf" %} + {%- include "_access_stream.conf" %} + proxy_pass {{ forwarding_host }}:{{ forwarding_port }}; access_log /data/logs/stream-{{ id }}_access.log stream; @@ -26,6 +28,8 @@ server { listen {{ incoming_port }} udp; {% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} udp; + {%- include "_access_stream.conf" %} + proxy_pass {{ forwarding_host }}:{{ forwarding_port }}; access_log /data/logs/stream-{{ id }}_access.log stream; diff --git a/frontend/src/api/backend/expansions.ts b/frontend/src/api/backend/expansions.ts index e098a49000..dc48712a6f 100644 --- a/frontend/src/api/backend/expansions.ts +++ b/frontend/src/api/backend/expansions.ts @@ -3,4 +3,5 @@ export type AuditLogExpansion = "user"; export type CertificateExpansion = "owner" | "proxy_hosts" | "redirection_hosts" | "dead_hosts" | "streams"; export type HostExpansion = "owner" | "certificate"; export type ProxyHostExpansion = "owner" | "access_list" | "certificate"; +export type StreamExpansion = "owner" | "certificate" | "access_list"; export type UserExpansion = "permissions"; diff --git a/frontend/src/api/backend/getStream.ts b/frontend/src/api/backend/getStream.ts index 82e10a0452..a3b1b8a090 100644 --- a/frontend/src/api/backend/getStream.ts +++ b/frontend/src/api/backend/getStream.ts @@ -1,8 +1,8 @@ import * as api from "./base"; -import type { HostExpansion } from "./expansions"; +import type { StreamExpansion } from "./expansions"; import type { Stream } from "./models"; -export async function getStream(id: number, expand?: HostExpansion[], params = {}): Promise { +export async function getStream(id: number, expand?: StreamExpansion[], params = {}): Promise { return await api.get({ url: `/nginx/streams/${id}`, params: { diff --git a/frontend/src/api/backend/getStreams.ts b/frontend/src/api/backend/getStreams.ts index b5e9379b52..5b242ac21c 100644 --- a/frontend/src/api/backend/getStreams.ts +++ b/frontend/src/api/backend/getStreams.ts @@ -1,8 +1,8 @@ import * as api from "./base"; -import type { HostExpansion } from "./expansions"; +import type { StreamExpansion } from "./expansions"; import type { Stream } from "./models"; -export async function getStreams(expand?: HostExpansion[], params = {}): Promise { +export async function getStreams(expand?: StreamExpansion[], params = {}): Promise { return await api.get({ url: "/nginx/streams", params: { diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index d63d47aecc..507514ad06 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -189,9 +189,11 @@ export interface Stream { meta: Record; enabled: boolean; certificateId: number; + accessListId: number; // Expansions: owner?: User; certificate?: Certificate; + accessList?: AccessList; } export interface Setting { diff --git a/frontend/src/hooks/useStream.ts b/frontend/src/hooks/useStream.ts index dfdddc1a08..b0b8c41a51 100644 --- a/frontend/src/hooks/useStream.ts +++ b/frontend/src/hooks/useStream.ts @@ -13,9 +13,10 @@ const fetchStream = (id: number | "new") => { meta: {}, enabled: true, certificateId: 0, + accessListId: 0, } as Stream); } - return getStream(id, ["owner"]); + return getStream(id, ["owner", "access_list"]); }; const useStream = (id: number | "new", options = {}) => { diff --git a/frontend/src/hooks/useStreams.ts b/frontend/src/hooks/useStreams.ts index 0f0129dec7..31d2502c45 100644 --- a/frontend/src/hooks/useStreams.ts +++ b/frontend/src/hooks/useStreams.ts @@ -1,11 +1,11 @@ import { useQuery } from "@tanstack/react-query"; -import { getStreams, type HostExpansion, type Stream } from "src/api/backend"; +import { getStreams, type StreamExpansion, type Stream } from "src/api/backend"; -const fetchStreams = (expand?: HostExpansion[]) => { +const fetchStreams = (expand?: StreamExpansion[]) => { return getStreams(expand); }; -const useStreams = (expand?: HostExpansion[], options = {}) => { +const useStreams = (expand?: StreamExpansion[], options = {}) => { return useQuery({ queryKey: ["streams", { expand }], queryFn: () => fetchStreams(expand), diff --git a/frontend/src/modals/StreamModal.tsx b/frontend/src/modals/StreamModal.tsx index 6d55348896..59a7d87508 100644 --- a/frontend/src/modals/StreamModal.tsx +++ b/frontend/src/modals/StreamModal.tsx @@ -3,7 +3,7 @@ import { Field, Form, Formik } from "formik"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; -import { Button, Loading, SSLCertificateField, SSLOptionsFields } from "src/components"; +import { AccessField, Button, Loading, SSLCertificateField, SSLOptionsFields } from "src/components"; import { useSetStream, useStream } from "src/hooks"; import { intl, T } from "src/locale"; import { validateNumber, validateString } from "src/modules/Validations"; @@ -63,6 +63,7 @@ const StreamModal = EasyModal.create(({ id, visible, remove }: Props) => { tcpForwarding: data?.tcpForwarding, udpForwarding: data?.udpForwarding, certificateId: data?.certificateId, + accessListId: data?.accessListId || 0, meta: data?.meta || {}, } as any } @@ -205,6 +206,7 @@ const StreamModal = EasyModal.create(({ id, visible, remove }: Props) => { +

diff --git a/frontend/src/pages/Nginx/Streams/Table.tsx b/frontend/src/pages/Nginx/Streams/Table.tsx index 4b9ff7d600..426c3a1bc2 100644 --- a/frontend/src/pages/Nginx/Streams/Table.tsx +++ b/frontend/src/pages/Nginx/Streams/Table.tsx @@ -3,6 +3,7 @@ import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/re import { useMemo } from "react"; import type { Stream } from "src/api/backend"; import { + AccessListFormatter, CertificateFormatter, EmptyData, GravatarFormatter, @@ -81,6 +82,13 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete, return ; }, }), + columnHelper.accessor((row: any) => row.accessList, { + id: "accessList", + header: intl.formatMessage({ id: "column.access" }), + cell: (info: any) => { + return ; + }, + }), columnHelper.accessor((row: any) => row.enabled, { id: "enabled", header: intl.formatMessage({ id: "column.status" }), diff --git a/frontend/src/pages/Nginx/Streams/TableWrapper.tsx b/frontend/src/pages/Nginx/Streams/TableWrapper.tsx index ec9a8d4090..b04d60284f 100644 --- a/frontend/src/pages/Nginx/Streams/TableWrapper.tsx +++ b/frontend/src/pages/Nginx/Streams/TableWrapper.tsx @@ -15,7 +15,7 @@ export default function TableWrapper() { const queryClient = useQueryClient(); const [search, setSearch] = useState(""); const [_deleteId, _setDeleteIdd] = useState(0); - const { isFetching, isLoading, isError, error, data } = useStreams(["owner", "certificate"]); + const { isFetching, isLoading, isError, error, data } = useStreams(["owner", "certificate", "access_list"]); if (isLoading) { return ;