const _ = require("lodash"); const http = require("http"); const https = require("https"); const { axios_request, stringify } = require("../../../utils/general"); const Mutex = require("async-mutex").Mutex; const registry = require("../../../utils/registry"); const { GrpcError, grpc } = require("../../../utils/grpc"); const USER_AGENT = "democratic-csi"; const __REGISTRY_NS__ = "SynologyHttpClient"; SYNO_ERRORS = { 18990002: { status: grpc.status.RESOURCE_EXHAUSTED, message: "The synology volume is out of disk space." }, 18990538: { status: grpc.status.ALREADY_EXISTS, message: "A LUN with this name already exists." }, 18990541: { status: grpc.status.RESOURCE_EXHAUSTED, message: "The maximum number of LUNS has been reached." }, 18990542: { status: grpc.status.RESOURCE_EXHAUSTED, message: "The maximum number if iSCSI target has been reached." }, 18990744: { status: grpc.status.ALREADY_EXISTS, message: "An iSCSI target with this name already exists." }, 18990532: { status: grpc.status.NOT_FOUND, message: "No such snapshot." }, 18990500: { status: grpc.status.INVALID_ARGUMENT, message: "Bad LUN type" }, 18990543: { status: grpc.status.RESOURCE_EXHAUSTED, message: "Maximum number of snapshots reached." }, 18990635: { status: grpc.status.INVALID_ARGUMENT, message: "Invalid ioPolicy." } } class SynologyError extends GrpcError { constructor(code, httpCode = undefined) { super(0, ""); this.synoCode = code; this.httpCode = httpCode; if (code > 0) { const error = SYNO_ERRORS[code] this.code = error?.status ?? grpc.status.UNKNOWN; this.message = error?.message ?? `An unknown error occurred when executing a synology command (code = ${code}).`; } else { this.code = grpc.status.UNKNOWN; this.message = `The synology webserver returned a status code ${httpCode}`; } } } class SynologyHttpClient { constructor(options = {}) { this.options = JSON.parse(JSON.stringify(options)); this.logger = console; this.doLoginMutex = new Mutex(); this.apiSerializeMutex = new Mutex(); if (false) { setInterval(() => { console.log("WIPING OUT SYNOLOGY SID"); this.sid = null; }, 5 * 1000); } } getHttpAgent() { return registry.get(`${__REGISTRY_NS__}:http_agent`, () => { return new http.Agent({ keepAlive: true, maxSockets: Infinity, rejectUnauthorized: !!!this.options.allowInsecure, }); }); } getHttpsAgent() { return registry.get(`${__REGISTRY_NS__}:https_agent`, () => { return new https.Agent({ keepAlive: true, maxSockets: Infinity, rejectUnauthorized: !!!this.options.allowInsecure, }); }); } log_response(error, response, body, options) { let prop; let val; prop = "auth.username"; val = _.get(options, prop, false); if (val) { _.set(options, prop, "redacted"); } prop = "auth.password"; val = _.get(options, prop, false); if (val) { _.set(options, prop, "redacted"); } prop = "headers.Authorization"; val = _.get(options, prop, false); if (val) { _.set(options, prop, "redacted"); } this.logger.debug("SYNOLOGY HTTP REQUEST: " + stringify(options)); this.logger.debug("SYNOLOGY HTTP ERROR: " + error); this.logger.debug("SYNOLOGY HTTP STATUS: " + response.statusCode); this.logger.debug("SYNOLOGY HTTP HEADERS: " + stringify(response.headers)); this.logger.debug("SYNOLOGY HTTP BODY: " + stringify(body)); } async do_request(method, path, data = {}, options = {}) { const client = this; const isAuth = data.api == "SYNO.API.Auth" && data.method == "login"; let sid; let apiMutexRelease; if (!isAuth) { sid = await this.doLoginMutex.runExclusive(async () => { return await this.login(); }); } const invoke_options = options; if (!isAuth) { if (this.options.serialize) { apiMutexRelease = await this.apiSerializeMutex.acquire(); } } return new Promise((resolve, reject) => { if (!isAuth) { data._sid = sid; } const options = { method: method, url: `${this.options.protocol}://${this.options.host}:${this.options.port}/webapi/${path}`, headers: { Accept: "application/json", "User-Agent": USER_AGENT, "Content-Type": invoke_options.use_form_encoded ? "application/x-www-form-urlencoded" : "application/json", }, responseType: "json", httpAgent: this.getHttpAgent(), httpsAgent: this.getHttpsAgent(), timeout: 60 * 1000, }; switch (method) { case "GET": let qsData = JSON.parse(JSON.stringify(data)); for (let p in qsData) { if (Array.isArray(qsData[p]) || typeof qsData[p] == "boolean") { qsData[p] = JSON.stringify(qsData[p]); } } options.params = qsData; break; default: if (invoke_options.use_form_encoded) { options.data = URLSearchParams(data).toString(); } else { options.data = data; } break; } try { axios_request(options, function (error, response, body) { client.log_response(...arguments, options); if (error) { reject(error); } if ( typeof response.body !== "object" && response.body !== null && response.headers["content-type"] && response.headers["content-type"].includes("application/json") ) { response.body = JSON.parse(response.body); } if (response.statusCode > 299 || response.statusCode < 200) { reject(new SynologyError(-1, response.statusCode)) } if (response.body.success === false) { // remove invalid sid if (response.body.error.code == 119 && sid == client.sid) { client.sid = null; } reject(new SynologyError(response.body.error.code, response.statusCode)); } resolve(response); }); } finally { if (typeof apiMutexRelease == "function") { apiMutexRelease(); } } }); } async login() { if (!this.sid) { // See https://global.download.synology.com/download/Document/Software/DeveloperGuide/Os/DSM/All/enu/DSM_Login_Web_API_Guide_enu.pdf const data = { api: "SYNO.API.Auth", version: "6", method: "login", account: this.options.username, passwd: this.options.password, session: this.options.session, format: "sid", }; let response = await this.do_request("GET", "auth.cgi", data); this.sid = response.body.data.sid; } return this.sid; } async GetLuns() { const lun_list = { api: "SYNO.Core.ISCSI.LUN", version: "1", method: "list", }; let response = await this.do_request("GET", "entry.cgi", lun_list); return response.body.data.luns; } async GetLunUUIDByName(name) { const lun_list = { api: "SYNO.Core.ISCSI.LUN", version: "1", method: "list", }; let response = await this.do_request("GET", "entry.cgi", lun_list); let lun = response.body.data.luns.find((i) => { return i.name == name; }); if (lun) { return lun.uuid; } } async GetLunIDByName(name) { const lun_list = { api: "SYNO.Core.ISCSI.LUN", version: "1", method: "list", }; let response = await this.do_request("GET", "entry.cgi", lun_list); let lun = response.body.data.luns.find((i) => { return i.name == name; }); if (lun) { return lun.lun_id; } } async GetLunByID(lun_id) { const lun_list = { api: "SYNO.Core.ISCSI.LUN", version: "1", method: "list", }; let response = await this.do_request("GET", "entry.cgi", lun_list); let lun = response.body.data.luns.find((i) => { return i.lun_id == lun_id; }); if (lun) { return lun; } } async GetLunByName(name) { const lun_list = { api: "SYNO.Core.ISCSI.LUN", version: "1", method: "list", }; let response = await this.do_request("GET", "entry.cgi", lun_list); let lun = response.body.data.luns.find((i) => { return i.name == name; }); if (lun) { return lun; } } async GetSnapshots() { let luns = await this.GetLuns(); let snapshots = []; for (let lun of luns) { const get_snapshot_info = { api: "SYNO.Core.ISCSI.LUN", method: "list_snapshot", version: 1, src_lun_uuid: JSON.stringify(lun.uuid), }; let response = await this.do_request( "GET", "entry.cgi", get_snapshot_info ); snapshots = snapshots.concat(response.body.data.snapshots); } return snapshots; } async GetSnapshotByLunUUIDAndName(lun_uuid, name) { const get_snapshot_info = { api: "SYNO.Core.ISCSI.LUN", method: "list_snapshot", version: 1, src_lun_uuid: JSON.stringify(lun_uuid), }; let response = await this.do_request("GET", "entry.cgi", get_snapshot_info); if (response.body.data.snapshots) { let snapshot = response.body.data.snapshots.find((i) => { return i.description == name; }); if (snapshot) { return snapshot; } } } async GetSnapshotByLunUUIDAndSnapshotUUID(lun_uuid, snapshot_uuid) { const get_snapshot_info = { api: "SYNO.Core.ISCSI.LUN", method: "list_snapshot", version: 1, src_lun_uuid: JSON.stringify(lun_uuid), }; let response = await this.do_request("GET", "entry.cgi", get_snapshot_info); if (response.body.data.snapshots) { let snapshot = response.body.data.snapshots.find((i) => { return i.uuid == snapshot_uuid; }); if (snapshot) { return snapshot; } } } async DeleteSnapshot(snapshot_uuid) { const iscsi_snapshot_delete = { api: "SYNO.Core.ISCSI.LUN", method: "delete_snapshot", version: 1, snapshot_uuid: JSON.stringify(snapshot_uuid), // snapshot_id deleted_by: "democratic_csi", // ? }; let response = await this.do_request( "GET", "entry.cgi", iscsi_snapshot_delete ); // return? } async GetVolumeInfo(volume_path) { let data = { api: "SYNO.Core.Storage.Volume", method: "get", version: "1", //volume_path: "/volume1", volume_path, }; return await this.do_request("GET", "entry.cgi", data); } async GetTargetByTargetID(target_id) { let targets = await this.ListTargets(); let target = targets.find((i) => { return i.target_id == target_id; }); return target; } async GetTargetByIQN(iqn) { let targets = await this.ListTargets(); let target = targets.find((i) => { return i.iqn == iqn; }); return target; } async ListTargets() { const iscsi_target_list = { api: "SYNO.Core.ISCSI.Target", version: "1", path: "entry.cgi", method: "list", additional: '["mapped_lun", "status", "acls", "connected_sessions"]', }; let response = await this.do_request("GET", "entry.cgi", iscsi_target_list); return response.body.data.targets; } async CreateLun(data = {}) { let response; let iscsi_lun_create = Object.assign({}, data, { api: "SYNO.Core.ISCSI.LUN", version: "1", method: "create", }); const lun_list = { api: "SYNO.Core.ISCSI.LUN", version: "1", method: "list", }; try { response = await this.do_request("GET", "entry.cgi", iscsi_lun_create); return response.body.data.uuid; } catch (err) { if (err.synoCode === 18990538) { response = await this.do_request("GET", "entry.cgi", lun_list); let lun = response.body.data.luns.find((i) => { return i.name == iscsi_lun_create.name; }); return lun.uuid; } else { throw err; } } } async MapLun(data = {}) { // this is mapping from the perspective of the lun let iscsi_target_map = Object.assign({}, data, { api: "SYNO.Core.ISCSI.LUN", method: "map_target", version: "1", }); iscsi_target_map.uuid = JSON.stringify(iscsi_target_map.uuid); iscsi_target_map.target_ids = JSON.stringify(iscsi_target_map.target_ids); // this is mapping from the perspective of the target /* iscsi_target_map = Object.assign(data, { api: "SYNO.Core.ISCSI.Target", method: "map_lun", version: "1", }); iscsi_target_map.lun_uuids = JSON.stringify(iscsi_target_map.lun_uuids); */ await this.do_request("GET", "entry.cgi", iscsi_target_map); } async DeleteLun(uuid) { uuid = uuid || ""; let iscsi_lun_delete = { api: "SYNO.Core.ISCSI.LUN", method: "delete", version: 1, //uuid: uuid, uuid: JSON.stringify(""), uuids: JSON.stringify([uuid]), //is_soft_feas_ignored: false, is_soft_feas_ignored: true, //feasibility_precheck: true, }; await this.do_request("GET", "entry.cgi", iscsi_lun_delete); } async DeleteAllLuns() { const lun_list = { api: "SYNO.Core.ISCSI.LUN", version: "1", method: "list", }; let response = await this.do_request("GET", "entry.cgi", lun_list); for (let lun of response.body.data.luns) { await this.DeleteLun(lun.uuid); } } async CreateSnapshot(data) { data = Object.assign({}, data, { api: "SYNO.Core.ISCSI.LUN", method: "take_snapshot", version: 1, }); data.src_lun_uuid = JSON.stringify(data.src_lun_uuid); return await this.do_request("GET", "entry.cgi", data); } async CreateTarget(data = {}) { let iscsi_target_create = Object.assign({}, data, { api: "SYNO.Core.ISCSI.Target", version: "1", method: "create", }); let response; try { response = await this.do_request("GET", "entry.cgi", iscsi_target_create); return response.body.data.target_id; } catch (err) { if (err.synoCode === 18990744) { //do lookup const iscsi_target_list = { api: "SYNO.Core.ISCSI.Target", version: "1", path: "entry.cgi", method: "list", additional: '["mapped_lun", "status", "acls", "connected_sessions"]', }; response = await this.do_request("GET", "entry.cgi", iscsi_target_list); let target = response.body.data.targets.find((i) => { return i.iqn == iscsi_target_create.iqn; }); if (target) { return target.target_id; } else { throw err; } } else { throw err; } } } async DeleteTarget(target_id) { const iscsi_target_delete = { api: "SYNO.Core.ISCSI.Target", method: "delete", version: "1", path: "entry.cgi", }; try { await this.do_request( "GET", "entry.cgi", Object.assign({}, iscsi_target_delete, { target_id: JSON.stringify(String(target_id || "")), }) ); } catch (err) { /** * 18990710 = non-existant */ //if (err.synoCode !== 18990710) { throw err; //} } } async ExpandISCSILun(uuid, size) { const iscsi_lun_extend = { api: "SYNO.Core.ISCSI.LUN", method: "set", version: 1, }; return await this.do_request( "GET", "entry.cgi", Object.assign({}, iscsi_lun_extend, { uuid: JSON.stringify(uuid), new_size: size, }) ); } async CreateClonedVolume(src_lun_uuid, dst_lun_name, dst_location, description) { const create_cloned_volume = { api: "SYNO.Core.ISCSI.LUN", version: 1, method: "clone", src_lun_uuid: JSON.stringify(src_lun_uuid), // src lun uuid dst_lun_name: dst_lun_name, // dst lun name dst_location: dst_location, is_same_pool: true, // always true? string? clone_type: "democratic-csi", // check }; if (description) { create_cloned_volume.description = description; } return await this.do_request("GET", "entry.cgi", create_cloned_volume); } async CreateVolumeFromSnapshot(src_lun_uuid, snapshot_uuid, cloned_lun_name) { const create_volume_from_snapshot = { api: "SYNO.Core.ISCSI.LUN", version: 1, method: "clone_snapshot", src_lun_uuid: JSON.stringify(src_lun_uuid), // src lun uuid, snapshot id? snapshot_uuid: JSON.stringify(snapshot_uuid), // shaptop uuid cloned_lun_name: cloned_lun_name, // cloned lun name clone_type: "democratic-csi", // check }; return await this.do_request( "GET", "entry.cgi", create_volume_from_snapshot ); } } module.exports.SynologyHttpClient = SynologyHttpClient;