access-list.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. import fs from "node:fs";
  2. import batchflow from "batchflow";
  3. import _ from "lodash";
  4. import errs from "../lib/error.js";
  5. import utils from "../lib/utils.js";
  6. import { access as logger } from "../logger.js";
  7. import accessListModel from "../models/access_list.js";
  8. import accessListAuthModel from "../models/access_list_auth.js";
  9. import accessListClientModel from "../models/access_list_client.js";
  10. import proxyHostModel from "../models/proxy_host.js";
  11. import internalAuditLog from "./audit-log.js";
  12. import internalNginx from "./nginx.js";
  13. const omissions = () => {
  14. return ["is_deleted"];
  15. };
  16. const internalAccessList = {
  17. /**
  18. * @param {Access} access
  19. * @param {Object} data
  20. * @returns {Promise}
  21. */
  22. create: async (access, data) => {
  23. await access.can("access_lists:create", data);
  24. const row = await accessListModel
  25. .query()
  26. .insertAndFetch({
  27. name: data.name,
  28. satisfy_any: data.satisfy_any,
  29. pass_auth: data.pass_auth,
  30. owner_user_id: access.token.getUserId(1),
  31. })
  32. .then(utils.omitRow(omissions()));
  33. data.id = row.id;
  34. const promises = [];
  35. // Items
  36. data.items.map((item) => {
  37. promises.push(
  38. accessListAuthModel.query().insert({
  39. access_list_id: row.id,
  40. username: item.username,
  41. password: item.password,
  42. }),
  43. );
  44. return true;
  45. });
  46. // Clients
  47. data.clients?.map((client) => {
  48. promises.push(
  49. accessListClientModel.query().insert({
  50. access_list_id: row.id,
  51. address: client.address,
  52. directive: client.directive,
  53. }),
  54. );
  55. return true;
  56. });
  57. await Promise.all(promises);
  58. // re-fetch with expansions
  59. const freshRow = await internalAccessList.get(
  60. access,
  61. {
  62. id: data.id,
  63. expand: ["owner", "items", "clients", "proxy_hosts.access_list.[clients,items]"],
  64. },
  65. true // skip masking
  66. );
  67. // Audit log
  68. data.meta = _.assign({}, data.meta || {}, freshRow.meta);
  69. await internalAccessList.build(freshRow);
  70. if (Number.parseInt(freshRow.proxy_host_count, 10)) {
  71. await internalNginx.bulkGenerateConfigs("proxy_host", freshRow.proxy_hosts);
  72. }
  73. // Add to audit log
  74. await internalAuditLog.add(access, {
  75. action: "created",
  76. object_type: "access-list",
  77. object_id: freshRow.id,
  78. meta: internalAccessList.maskItems(data),
  79. });
  80. return internalAccessList.maskItems(freshRow);
  81. },
  82. /**
  83. * @param {Access} access
  84. * @param {Object} data
  85. * @param {Integer} data.id
  86. * @param {String} [data.name]
  87. * @param {String} [data.items]
  88. * @return {Promise}
  89. */
  90. update: async (access, data) => {
  91. await access.can("access_lists:update", data.id);
  92. const row = await internalAccessList.get(access, { id: data.id });
  93. if (row.id !== data.id) {
  94. // Sanity check that something crazy hasn't happened
  95. throw new errs.InternalValidationError(
  96. `Access List could not be updated, IDs do not match: ${row.id} !== ${data.id}`,
  97. );
  98. }
  99. // patch name if specified
  100. if (typeof data.name !== "undefined" && data.name) {
  101. await accessListModel.query().where({ id: data.id }).patch({
  102. name: data.name,
  103. satisfy_any: data.satisfy_any,
  104. pass_auth: data.pass_auth,
  105. });
  106. }
  107. // Check for items and add/update/remove them
  108. if (typeof data.items !== "undefined" && data.items) {
  109. const promises = [];
  110. const itemsToKeep = [];
  111. data.items.map((item) => {
  112. if (item.password) {
  113. promises.push(
  114. accessListAuthModel.query().insert({
  115. access_list_id: data.id,
  116. username: item.username,
  117. password: item.password,
  118. }),
  119. );
  120. } else {
  121. // This was supplied with an empty password, which means keep it but don't change the password
  122. itemsToKeep.push(item.username);
  123. }
  124. return true;
  125. });
  126. const query = accessListAuthModel.query().delete().where("access_list_id", data.id);
  127. if (itemsToKeep.length) {
  128. query.andWhere("username", "NOT IN", itemsToKeep);
  129. }
  130. await query;
  131. // Add new items
  132. if (promises.length) {
  133. await Promise.all(promises);
  134. }
  135. }
  136. // Check for clients and add/update/remove them
  137. if (typeof data.clients !== "undefined" && data.clients) {
  138. const clientPromises = [];
  139. data.clients.map((client) => {
  140. if (client.address) {
  141. clientPromises.push(
  142. accessListClientModel.query().insert({
  143. access_list_id: data.id,
  144. address: client.address,
  145. directive: client.directive,
  146. }),
  147. );
  148. }
  149. return true;
  150. });
  151. const query = accessListClientModel.query().delete().where("access_list_id", data.id);
  152. await query;
  153. // Add new clitens
  154. if (clientPromises.length) {
  155. await Promise.all(clientPromises);
  156. }
  157. }
  158. // Add to audit log
  159. await internalAuditLog.add(access, {
  160. action: "updated",
  161. object_type: "access-list",
  162. object_id: data.id,
  163. meta: internalAccessList.maskItems(data),
  164. });
  165. // re-fetch with expansions
  166. const freshRow = await internalAccessList.get(
  167. access,
  168. {
  169. id: data.id,
  170. expand: ["owner", "items", "clients", "proxy_hosts.[certificate,access_list.[clients,items]]"],
  171. },
  172. true // skip masking
  173. );
  174. await internalAccessList.build(freshRow)
  175. if (Number.parseInt(freshRow.proxy_host_count, 10)) {
  176. await internalNginx.bulkGenerateConfigs("proxy_host", freshRow.proxy_hosts);
  177. }
  178. await internalNginx.reload();
  179. return internalAccessList.maskItems(freshRow);
  180. },
  181. /**
  182. * @param {Access} access
  183. * @param {Object} data
  184. * @param {Integer} data.id
  185. * @param {Array} [data.expand]
  186. * @param {Array} [data.omit]
  187. * @param {Boolean} [skipMasking]
  188. * @return {Promise}
  189. */
  190. get: async (access, data, skipMasking) => {
  191. const thisData = data || {};
  192. const accessData = await access.can("access_lists:get", thisData.id)
  193. const query = accessListModel
  194. .query()
  195. .select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count"))
  196. .leftJoin("proxy_host", function () {
  197. this.on("proxy_host.access_list_id", "=", "access_list.id").andOn(
  198. "proxy_host.is_deleted",
  199. "=",
  200. 0,
  201. );
  202. })
  203. .where("access_list.is_deleted", 0)
  204. .andWhere("access_list.id", thisData.id)
  205. .groupBy("access_list.id")
  206. .allowGraph("[owner,items,clients,proxy_hosts.[certificate,access_list.[clients,items]]]")
  207. .first();
  208. if (accessData.permission_visibility !== "all") {
  209. query.andWhere("access_list.owner_user_id", access.token.getUserId(1));
  210. }
  211. if (typeof thisData.expand !== "undefined" && thisData.expand !== null) {
  212. query.withGraphFetched(`[${thisData.expand.join(", ")}]`);
  213. }
  214. let row = await query.then(utils.omitRow(omissions()));
  215. if (!row || !row.id) {
  216. throw new errs.ItemNotFoundError(thisData.id);
  217. }
  218. if (!skipMasking && typeof row.items !== "undefined" && row.items) {
  219. row = internalAccessList.maskItems(row);
  220. }
  221. // Custom omissions
  222. if (typeof data.omit !== "undefined" && data.omit !== null) {
  223. row = _.omit(row, data.omit);
  224. }
  225. return row;
  226. },
  227. /**
  228. * @param {Access} access
  229. * @param {Object} data
  230. * @param {Integer} data.id
  231. * @param {String} [data.reason]
  232. * @returns {Promise}
  233. */
  234. delete: async (access, data) => {
  235. await access.can("access_lists:delete", data.id);
  236. const row = await internalAccessList.get(access, {
  237. id: data.id,
  238. expand: ["proxy_hosts", "items", "clients"],
  239. });
  240. if (!row || !row.id) {
  241. throw new errs.ItemNotFoundError(data.id);
  242. }
  243. // 1. update row to be deleted
  244. // 2. update any proxy hosts that were using it (ignoring permissions)
  245. // 3. reconfigure those hosts
  246. // 4. audit log
  247. // 1. update row to be deleted
  248. await accessListModel
  249. .query()
  250. .where("id", row.id)
  251. .patch({
  252. is_deleted: 1,
  253. });
  254. // 2. update any proxy hosts that were using it (ignoring permissions)
  255. if (row.proxy_hosts) {
  256. await proxyHostModel
  257. .query()
  258. .where("access_list_id", "=", row.id)
  259. .patch({ access_list_id: 0 });
  260. // 3. reconfigure those hosts, then reload nginx
  261. // set the access_list_id to zero for these items
  262. row.proxy_hosts.map((_val, idx) => {
  263. row.proxy_hosts[idx].access_list_id = 0;
  264. return true;
  265. });
  266. await internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts);
  267. }
  268. await internalNginx.reload();
  269. // delete the htpasswd file
  270. try {
  271. fs.unlinkSync(internalAccessList.getFilename(row));
  272. } catch (_err) {
  273. // do nothing
  274. }
  275. // 4. audit log
  276. await internalAuditLog.add(access, {
  277. action: "deleted",
  278. object_type: "access-list",
  279. object_id: row.id,
  280. meta: _.omit(internalAccessList.maskItems(row), ["is_deleted", "proxy_hosts"]),
  281. });
  282. return true;
  283. },
  284. /**
  285. * All Lists
  286. *
  287. * @param {Access} access
  288. * @param {Array} [expand]
  289. * @param {String} [searchQuery]
  290. * @returns {Promise}
  291. */
  292. getAll: async (access, expand, searchQuery) => {
  293. const accessData = await access.can("access_lists:list");
  294. const query = accessListModel
  295. .query()
  296. .select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count"))
  297. .leftJoin("proxy_host", function () {
  298. this.on("proxy_host.access_list_id", "=", "access_list.id").andOn(
  299. "proxy_host.is_deleted",
  300. "=",
  301. 0,
  302. );
  303. })
  304. .where("access_list.is_deleted", 0)
  305. .groupBy("access_list.id")
  306. .allowGraph("[owner,items,clients]")
  307. .orderBy("access_list.name", "ASC");
  308. if (accessData.permission_visibility !== "all") {
  309. query.andWhere("access_list.owner_user_id", access.token.getUserId(1));
  310. }
  311. // Query is used for searching
  312. if (typeof searchQuery === "string") {
  313. query.where(function () {
  314. this.where("name", "like", `%${searchQuery}%`);
  315. });
  316. }
  317. if (typeof expand !== "undefined" && expand !== null) {
  318. query.withGraphFetched(`[${expand.join(", ")}]`);
  319. }
  320. const rows = await query.then(utils.omitRows(omissions()));
  321. if (rows) {
  322. rows.map((row, idx) => {
  323. if (typeof row.items !== "undefined" && row.items) {
  324. rows[idx] = internalAccessList.maskItems(row);
  325. }
  326. return true;
  327. });
  328. }
  329. return rows;
  330. },
  331. /**
  332. * Count is used in reports
  333. *
  334. * @param {Integer} userId
  335. * @param {String} visibility
  336. * @returns {Promise}
  337. */
  338. getCount: async (userId, visibility) => {
  339. const query = accessListModel
  340. .query()
  341. .count("id as count")
  342. .where("is_deleted", 0);
  343. if (visibility !== "all") {
  344. query.andWhere("owner_user_id", userId);
  345. }
  346. const row = await query.first();
  347. return Number.parseInt(row.count, 10);
  348. },
  349. /**
  350. * @param {Object} list
  351. * @returns {Object}
  352. */
  353. maskItems: (list) => {
  354. if (list && typeof list.items !== "undefined") {
  355. list.items.map((val, idx) => {
  356. let repeatFor = 8;
  357. let firstChar = "*";
  358. if (typeof val.password !== "undefined" && val.password) {
  359. repeatFor = val.password.length - 1;
  360. firstChar = val.password.charAt(0);
  361. }
  362. list.items[idx].hint = firstChar + "*".repeat(repeatFor);
  363. list.items[idx].password = "";
  364. return true;
  365. });
  366. }
  367. return list;
  368. },
  369. /**
  370. * @param {Object} list
  371. * @param {Integer} list.id
  372. * @returns {String}
  373. */
  374. getFilename: (list) => {
  375. return `/data/access/${list.id}`;
  376. },
  377. /**
  378. * @param {Object} list
  379. * @param {Integer} list.id
  380. * @param {String} list.name
  381. * @param {Array} list.items
  382. * @returns {Promise}
  383. */
  384. build: async (list) => {
  385. logger.info(`Building Access file #${list.id} for: ${list.name}`);
  386. const htpasswdFile = internalAccessList.getFilename(list);
  387. // 1. remove any existing access file
  388. try {
  389. fs.unlinkSync(htpasswdFile);
  390. } catch (_err) {
  391. // do nothing
  392. }
  393. // 2. create empty access file
  394. fs.writeFileSync(htpasswdFile, '', {encoding: 'utf8'});
  395. // 3. generate password for each user
  396. if (list.items.length) {
  397. await new Promise((resolve, reject) => {
  398. batchflow(list.items).sequential()
  399. .each((_i, item, next) => {
  400. if (item.password?.length) {
  401. logger.info(`Adding: ${item.username}`);
  402. utils.execFile('openssl', ['passwd', '-apr1', item.password])
  403. .then((res) => {
  404. try {
  405. fs.appendFileSync(htpasswdFile, `${item.username}:${res}\n`, {encoding: 'utf8'});
  406. } catch (err) {
  407. reject(err);
  408. }
  409. next();
  410. })
  411. .catch((err) => {
  412. logger.error(err);
  413. next(err);
  414. });
  415. }
  416. })
  417. .error((err) => {
  418. logger.error(err);
  419. reject(err);
  420. })
  421. .end((results) => {
  422. logger.success(`Built Access file #${list.id} for: ${list.name}`);
  423. resolve(results);
  424. });
  425. });
  426. }
  427. }
  428. }
  429. export default internalAccessList;