import fs from "node:fs"; import batchflow from "batchflow"; import _ from "lodash"; import errs from "../lib/error.js"; import utils from "../lib/utils.js"; import { access as logger } from "../logger.js"; 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 internalAuditLog from "./audit-log.js"; import internalNginx from "./nginx.js"; const omissions = () => { return ["is_deleted"]; }; const internalAccessList = { /** * @param {Access} access * @param {Object} data * @returns {Promise} */ create: (access, data) => { return access .can("access_lists:create", data) .then((/*access_data*/) => { return accessListModel .query() .insertAndFetch({ name: data.name, satisfy_any: data.satisfy_any, pass_auth: data.pass_auth, owner_user_id: access.token.getUserId(1), }) .then(utils.omitRow(omissions())); }) .then((row) => { data.id = row.id; const promises = []; // Now add the items data.items.map((item) => { promises.push( accessListAuthModel.query().insert({ access_list_id: row.id, username: item.username, password: item.password, }), ); return true; }); // Now add the clients if (typeof data.clients !== "undefined" && data.clients) { data.clients.map((client) => { promises.push( accessListClientModel.query().insert({ access_list_id: row.id, address: client.address, directive: client.directive, }), ); return true; }); } return Promise.all(promises); }) .then(() => { // re-fetch with expansions return internalAccessList.get( access, { id: data.id, expand: ["owner", "items", "clients", "proxy_hosts.access_list.[clients,items]"], }, true /* <- skip masking */, ); }) .then((row) => { // Audit log data.meta = _.assign({}, data.meta || {}, row.meta); return internalAccessList .build(row) .then(() => { if (Number.parseInt(row.proxy_host_count, 10)) { return internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts); } }) .then(() => { // Add to audit log return internalAuditLog.add(access, { action: "created", object_type: "access-list", object_id: row.id, meta: internalAccessList.maskItems(data), }); }) .then(() => { return internalAccessList.maskItems(row); }); }); }, /** * @param {Access} access * @param {Object} data * @param {Integer} data.id * @param {String} [data.name] * @param {String} [data.items] * @return {Promise} */ update: (access, data) => { return access .can("access_lists:update", data.id) .then((/*access_data*/) => { return internalAccessList.get(access, { id: data.id }); }) .then((row) => { if (row.id !== data.id) { // Sanity check that something crazy hasn't happened throw new errs.InternalValidationError( `Access List could not be updated, IDs do not match: ${row.id} !== ${data.id}`, ); } }) .then(() => { // patch name if specified if (typeof data.name !== "undefined" && data.name) { return accessListModel.query().where({ id: data.id }).patch({ name: data.name, satisfy_any: data.satisfy_any, pass_auth: data.pass_auth, }); } }) .then(() => { // Check for items and add/update/remove them if (typeof data.items !== "undefined" && data.items) { const promises = []; const items_to_keep = []; data.items.map((item) => { if (item.password) { promises.push( accessListAuthModel.query().insert({ access_list_id: data.id, username: item.username, password: item.password, }), ); } else { // This was supplied with an empty password, which means keep it but don't change the password items_to_keep.push(item.username); } return true; }); const query = accessListAuthModel.query().delete().where("access_list_id", data.id); if (items_to_keep.length) { query.andWhere("username", "NOT IN", items_to_keep); } return query.then(() => { // Add new items if (promises.length) { return Promise.all(promises); } }); } }) .then(() => { // Check for clients and add/update/remove them if (typeof data.clients !== "undefined" && data.clients) { const promises = []; data.clients.map((client) => { if (client.address) { promises.push( accessListClientModel.query().insert({ access_list_id: data.id, address: client.address, directive: client.directive, }), ); } return true; }); const query = accessListClientModel.query().delete().where("access_list_id", data.id); return query.then(() => { // Add new items if (promises.length) { return Promise.all(promises); } }); } }) .then(() => { // Add to audit log return internalAuditLog.add(access, { action: "updated", object_type: "access-list", object_id: data.id, meta: internalAccessList.maskItems(data), }); }) .then(() => { // re-fetch with expansions return internalAccessList.get( access, { id: data.id, expand: ["owner", "items", "clients", "proxy_hosts.[certificate,access_list.[clients,items]]"], }, true /* <- skip masking */, ); }) .then((row) => { return internalAccessList .build(row) .then(() => { if (Number.parseInt(row.proxy_host_count, 10)) { return internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts); } }) .then(internalNginx.reload) .then(() => { return internalAccessList.maskItems(row); }); }); }, /** * @param {Access} access * @param {Object} data * @param {Integer} data.id * @param {Array} [data.expand] * @param {Array} [data.omit] * @param {Boolean} [skip_masking] * @return {Promise} */ get: (access, data, skip_masking) => { const thisData = data || {}; return access .can("access_lists:get", thisData.id) .then((accessData) => { const query = accessListModel .query() .select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count")) .leftJoin("proxy_host", function () { this.on("proxy_host.access_list_id", "=", "access_list.id").andOn( "proxy_host.is_deleted", "=", 0, ); }) .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]]]") .first(); if (accessData.permission_visibility !== "all") { query.andWhere("access_list.owner_user_id", access.token.getUserId(1)); } if (typeof thisData.expand !== "undefined" && thisData.expand !== null) { query.withGraphFetched(`[${thisData.expand.join(", ")}]`); } return query.then(utils.omitRow(omissions())); }) .then((row) => { let thisRow = row; if (!row || !row.id) { throw new errs.ItemNotFoundError(thisData.id); } if (!skip_masking && typeof thisRow.items !== "undefined" && thisRow.items) { thisRow = internalAccessList.maskItems(thisRow); } // Custom omissions if (typeof data.omit !== "undefined" && data.omit !== null) { thisRow = _.omit(thisRow, data.omit); } return thisRow; }); }, /** * @param {Access} access * @param {Object} data * @param {Integer} data.id * @param {String} [data.reason] * @returns {Promise} */ delete: (access, data) => { return access .can("access_lists:delete", data.id) .then(() => { return internalAccessList.get(access, { id: data.id, expand: ["proxy_hosts", "items", "clients"] }); }) .then((row) => { if (!row || !row.id) { throw new errs.ItemNotFoundError(data.id); } // 1. update row to be deleted // 2. update any proxy hosts that were using it (ignoring permissions) // 3. reconfigure those hosts // 4. audit log // 1. update row to be deleted return accessListModel .query() .where("id", row.id) .patch({ is_deleted: 1, }) .then(() => { // 2. update any proxy hosts that were using it (ignoring permissions) if (row.proxy_hosts) { return proxyHostModel .query() .where("access_list_id", "=", row.id) .patch({ access_list_id: 0 }) .then(() => { // 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; }); return internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts); }) .then(() => { return internalNginx.reload(); }); } }) .then(() => { // delete the htpasswd file const htpasswd_file = internalAccessList.getFilename(row); try { fs.unlinkSync(htpasswd_file); } catch (_err) { // do nothing } }) .then(() => { // 4. audit log return internalAuditLog.add(access, { action: "deleted", object_type: "access-list", object_id: row.id, meta: _.omit(internalAccessList.maskItems(row), ["is_deleted", "proxy_hosts"]), }); }); }) .then(() => { return true; }); }, /** * All Lists * * @param {Access} access * @param {Array} [expand] * @param {String} [search_query] * @returns {Promise} */ getAll: (access, expand, search_query) => { return access .can("access_lists:list") .then((access_data) => { const query = accessListModel .query() .select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count")) .leftJoin("proxy_host", function () { this.on("proxy_host.access_list_id", "=", "access_list.id").andOn( "proxy_host.is_deleted", "=", 0, ); }) .where("access_list.is_deleted", 0) .groupBy("access_list.id") .allowGraph("[owner,items,clients]") .orderBy("access_list.name", "ASC"); if (access_data.permission_visibility !== "all") { query.andWhere("access_list.owner_user_id", access.token.getUserId(1)); } // Query is used for searching if (typeof search_query === "string") { query.where(function () { this.where("name", "like", `%${search_query}%`); }); } if (typeof expand !== "undefined" && expand !== null) { query.withGraphFetched(`[${expand.join(", ")}]`); } return query.then(utils.omitRows(omissions())); }) .then((rows) => { if (rows) { rows.map((row, idx) => { if (typeof row.items !== "undefined" && row.items) { rows[idx] = internalAccessList.maskItems(row); } return true; }); } return rows; }); }, /** * Report use * * @param {Integer} user_id * @param {String} visibility * @returns {Promise} */ getCount: (user_id, visibility) => { const query = accessListModel.query().count("id as count").where("is_deleted", 0); if (visibility !== "all") { query.andWhere("owner_user_id", user_id); } return query.first().then((row) => { return Number.parseInt(row.count, 10); }); }, /** * @param {Object} list * @returns {Object} */ maskItems: (list) => { if (list && typeof list.items !== "undefined") { list.items.map((val, idx) => { let repeat_for = 8; let first_char = "*"; if (typeof val.password !== "undefined" && val.password) { repeat_for = val.password.length - 1; first_char = val.password.charAt(0); } list.items[idx].hint = first_char + "*".repeat(repeat_for); list.items[idx].password = ""; return true; }); } return list; }, /** * @param {Object} list * @param {Integer} list.id * @returns {String} */ getFilename: (list) => { return `/data/access/${list.id}`; }, /** * @param {Object} list * @param {Integer} list.id * @param {String} list.name * @param {Array} list.items * @returns {Promise} */ build: (list) => { logger.info(`Building Access file #${list.id} for: ${list.name}`); return new Promise((resolve, reject) => { const htpasswd_file = internalAccessList.getFilename(list); // 1. remove any existing access file try { fs.unlinkSync(htpasswd_file); } catch (_err) { // do nothing } // 2. create empty access file try { fs.writeFileSync(htpasswd_file, "", { encoding: "utf8" }); resolve(htpasswd_file); } catch (err) { reject(err); } }).then((htpasswd_file) => { // 3. generate password for each user if (list.items.length) { return new Promise((resolve, reject) => { batchflow(list.items) .sequential() .each((_i, item, next) => { if (typeof item.password !== "undefined" && item.password.length) { logger.info(`Adding: ${item.username}`); utils .execFile("openssl", ["passwd", "-apr1", item.password]) .then((res) => { try { fs.appendFileSync(htpasswd_file, `${item.username}:${res}\n`, { encoding: "utf8", }); } catch (err) { reject(err); } next(); }) .catch((err) => { logger.error(err); next(err); }); } }) .error((err) => { logger.error(err); reject(err); }) .end((results) => { logger.success(`Built Access file #${list.id} for: ${list.name}`); resolve(results); }); }); } }); }, }; export default internalAccessList;