access-list.js 14 KB

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