cisco-ise 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/EXAMPLES.md +46 -0
- package/RADIUS.md +19 -0
- package/README.md +222 -0
- package/bin/cisco-ise.js +14 -0
- package/cli/commands/auth-profile.js +36 -0
- package/cli/commands/config.js +140 -0
- package/cli/commands/deployment.js +49 -0
- package/cli/commands/endpoint.js +167 -0
- package/cli/commands/guest.js +220 -0
- package/cli/commands/identity-group.js +24 -0
- package/cli/commands/internal-user.js +162 -0
- package/cli/commands/network-device.js +167 -0
- package/cli/commands/radius.js +326 -0
- package/cli/commands/session.js +123 -0
- package/cli/commands/tacacs.js +125 -0
- package/cli/commands/trustsec.js +37 -0
- package/cli/formatters/csv.js +10 -0
- package/cli/formatters/json.js +5 -0
- package/cli/formatters/table.js +29 -0
- package/cli/formatters/toon.js +6 -0
- package/cli/index.js +44 -0
- package/cli/utils/api.js +297 -0
- package/cli/utils/audit.js +30 -0
- package/cli/utils/config.js +125 -0
- package/cli/utils/confirm.js +34 -0
- package/cli/utils/connection.js +47 -0
- package/cli/utils/failure-reasons.js +2086 -0
- package/cli/utils/mac.js +18 -0
- package/cli/utils/output.js +42 -0
- package/cli/utils/spinner.js +19 -0
- package/cli/utils/time.js +21 -0
- package/cli/utils/wordlist.js +9 -0
- package/docs/PHASES.md +38 -0
- package/package.json +45 -0
- package/skills/cisco-ise-cli/SKILL.md +346 -0
- package/test/cli/api.test.js +67 -0
- package/test/cli/audit.test.js +31 -0
- package/test/cli/config.test.js +60 -0
- package/test/cli/confirm.test.js +34 -0
- package/test/cli/connection.test.js +54 -0
- package/test/cli/formatters.test.js +41 -0
- package/test/cli/mac.test.js +37 -0
- package/test/cli/time.test.js +30 -0
- package/test/integration/ise.test.js +425 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const { resolveConnection } = require("../utils/connection.js");
|
|
2
|
+
const { printResult, printError } = require("../utils/output.js");
|
|
3
|
+
const { resolveTimeRange } = require("../utils/time.js");
|
|
4
|
+
const IseClient = require("../utils/api.js");
|
|
5
|
+
|
|
6
|
+
module.exports = function (program) {
|
|
7
|
+
const cmd = program.command("tacacs").description("TACACS+ monitoring and configuration");
|
|
8
|
+
|
|
9
|
+
cmd.command("failures")
|
|
10
|
+
.description("Show TACACS+ authentication failures")
|
|
11
|
+
.option("--last <duration>", "time window (e.g., 30m, 2h, 1d)", "1h")
|
|
12
|
+
.option("--user <user>", "filter by username")
|
|
13
|
+
.option("--nas <nas>", "filter by NAS IP")
|
|
14
|
+
.action(async (opts, command) => {
|
|
15
|
+
try {
|
|
16
|
+
const globalOpts = command.optsWithGlobals();
|
|
17
|
+
const conn = resolveConnection(globalOpts);
|
|
18
|
+
const client = new IseClient(conn, { noCache: true, debug: globalOpts.debug });
|
|
19
|
+
|
|
20
|
+
const { from, to } = resolveTimeRange({ last: opts.last });
|
|
21
|
+
|
|
22
|
+
const data = await client.mntGet("/Session/AuthList/null/null");
|
|
23
|
+
let records = [];
|
|
24
|
+
const authList = data?.authStatusList?.authStatusElements;
|
|
25
|
+
if (authList) {
|
|
26
|
+
const list = Array.isArray(authList) ? authList : [authList];
|
|
27
|
+
records = list.filter((r) => {
|
|
28
|
+
if (r.passed === "true" || r.passed === true) return false;
|
|
29
|
+
const ts = new Date(r.acs_timestamp || 0).getTime();
|
|
30
|
+
return ts >= from && ts <= to;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (opts.user) {
|
|
35
|
+
const search = opts.user.toLowerCase();
|
|
36
|
+
records = records.filter((r) => (r.user_name || "").toLowerCase().includes(search));
|
|
37
|
+
}
|
|
38
|
+
if (opts.nas) {
|
|
39
|
+
records = records.filter((r) => (r.nas_ip_address || "").includes(opts.nas));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const results = records.map((r) => ({
|
|
43
|
+
timestamp: r.acs_timestamp || "",
|
|
44
|
+
user: r.user_name || "",
|
|
45
|
+
nas: r.nas_ip_address || "",
|
|
46
|
+
reason: r.failure_reason || "Unknown",
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
await printResult(results, globalOpts.format);
|
|
50
|
+
} catch (err) { printError(err); }
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
cmd.command("live")
|
|
54
|
+
.description("Live polling of TACACS+ events (Ctrl+C to stop)")
|
|
55
|
+
.option("--interval <seconds>", "polling interval in seconds", parseInt, 5)
|
|
56
|
+
.action(async (opts, command) => {
|
|
57
|
+
try {
|
|
58
|
+
const globalOpts = command.optsWithGlobals();
|
|
59
|
+
const conn = resolveConnection(globalOpts);
|
|
60
|
+
const client = new IseClient(conn, { noCache: true, debug: globalOpts.debug });
|
|
61
|
+
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
const interval = (opts.interval || 5) * 1000;
|
|
64
|
+
|
|
65
|
+
process.stderr.write("Live TACACS+ monitoring started. Press Ctrl+C to stop.\n");
|
|
66
|
+
|
|
67
|
+
const poll = async () => {
|
|
68
|
+
try {
|
|
69
|
+
const data = await client.mntGet("/Session/ActiveList");
|
|
70
|
+
const sessions = data?.activeList?.activeSession;
|
|
71
|
+
if (!sessions) return;
|
|
72
|
+
const list = Array.isArray(sessions) ? sessions : [sessions];
|
|
73
|
+
for (const s of list) {
|
|
74
|
+
const id = s.acct_session_id;
|
|
75
|
+
if (id && !seen.has(id)) {
|
|
76
|
+
seen.add(id);
|
|
77
|
+
console.log(JSON.stringify({
|
|
78
|
+
timestamp: new Date().toISOString(),
|
|
79
|
+
user: s.user_name || "",
|
|
80
|
+
nas: s.nas_ip_address || "",
|
|
81
|
+
status: s.acct_status_type || "",
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
process.stderr.write(`Poll error: ${err.message}\n`);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
await poll();
|
|
91
|
+
const timer = setInterval(poll, interval);
|
|
92
|
+
process.on("SIGINT", () => {
|
|
93
|
+
clearInterval(timer);
|
|
94
|
+
process.stderr.write("\nStopped.\n");
|
|
95
|
+
process.exit(0);
|
|
96
|
+
});
|
|
97
|
+
} catch (err) { printError(err); }
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
cmd.command("command-sets")
|
|
101
|
+
.description("List TACACS+ command sets")
|
|
102
|
+
.action(async (opts, command) => {
|
|
103
|
+
try {
|
|
104
|
+
const globalOpts = command.optsWithGlobals();
|
|
105
|
+
const conn = resolveConnection(globalOpts);
|
|
106
|
+
const client = new IseClient(conn, { noCache: !globalOpts.cache, debug: globalOpts.debug });
|
|
107
|
+
|
|
108
|
+
const resources = await client.ersPaginateAll("/tacacscommandsets");
|
|
109
|
+
await printResult(resources, globalOpts.format);
|
|
110
|
+
} catch (err) { printError(err); }
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
cmd.command("profiles")
|
|
114
|
+
.description("List TACACS+ profiles")
|
|
115
|
+
.action(async (opts, command) => {
|
|
116
|
+
try {
|
|
117
|
+
const globalOpts = command.optsWithGlobals();
|
|
118
|
+
const conn = resolveConnection(globalOpts);
|
|
119
|
+
const client = new IseClient(conn, { noCache: !globalOpts.cache, debug: globalOpts.debug });
|
|
120
|
+
|
|
121
|
+
const resources = await client.ersPaginateAll("/tacacsprofile");
|
|
122
|
+
await printResult(resources, globalOpts.format);
|
|
123
|
+
} catch (err) { printError(err); }
|
|
124
|
+
});
|
|
125
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const { resolveConnection } = require("../utils/connection.js");
|
|
2
|
+
const { printResult, printError } = require("../utils/output.js");
|
|
3
|
+
const IseClient = require("../utils/api.js");
|
|
4
|
+
|
|
5
|
+
module.exports = function (program) {
|
|
6
|
+
const cmd = program.command("trustsec").description("TrustSec SGT and SGACL management (read-only)");
|
|
7
|
+
|
|
8
|
+
const sgt = cmd.command("sgt").description("Security Group Tags");
|
|
9
|
+
|
|
10
|
+
sgt.command("list")
|
|
11
|
+
.description("List all SGTs")
|
|
12
|
+
.action(async (opts, command) => {
|
|
13
|
+
try {
|
|
14
|
+
const globalOpts = command.optsWithGlobals();
|
|
15
|
+
const conn = resolveConnection(globalOpts);
|
|
16
|
+
const client = new IseClient(conn, { noCache: !globalOpts.cache, debug: globalOpts.debug });
|
|
17
|
+
|
|
18
|
+
const resources = await client.ersPaginateAll("/sgt");
|
|
19
|
+
await printResult(resources, globalOpts.format);
|
|
20
|
+
} catch (err) { printError(err); }
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const sgacl = cmd.command("sgacl").description("Security Group ACLs");
|
|
24
|
+
|
|
25
|
+
sgacl.command("list")
|
|
26
|
+
.description("List all SGACLs")
|
|
27
|
+
.action(async (opts, command) => {
|
|
28
|
+
try {
|
|
29
|
+
const globalOpts = command.optsWithGlobals();
|
|
30
|
+
const conn = resolveConnection(globalOpts);
|
|
31
|
+
const client = new IseClient(conn, { noCache: !globalOpts.cache, debug: globalOpts.debug });
|
|
32
|
+
|
|
33
|
+
const resources = await client.ersPaginateAll("/sgacl");
|
|
34
|
+
await printResult(resources, globalOpts.format);
|
|
35
|
+
} catch (err) { printError(err); }
|
|
36
|
+
});
|
|
37
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const { stringify } = require("csv-stringify/sync");
|
|
2
|
+
|
|
3
|
+
function formatCsv(data) {
|
|
4
|
+
const rows = Array.isArray(data) ? data : [data];
|
|
5
|
+
if (rows.length === 0) return "";
|
|
6
|
+
const columns = Object.keys(rows[0]);
|
|
7
|
+
return stringify(rows, { header: true, columns });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
module.exports = formatCsv;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const Table = require("cli-table3");
|
|
2
|
+
|
|
3
|
+
function formatTable(data) {
|
|
4
|
+
if (Array.isArray(data)) return formatListTable(data);
|
|
5
|
+
return formatItemTable(data);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function formatListTable(rows) {
|
|
9
|
+
if (rows.length === 0) return "No results found";
|
|
10
|
+
const columns = Object.keys(rows[0]);
|
|
11
|
+
const table = new Table({ head: columns });
|
|
12
|
+
for (const row of rows) {
|
|
13
|
+
table.push(columns.map((col) => String(row[col] ?? "")));
|
|
14
|
+
}
|
|
15
|
+
return `${table.toString()}\n${rows.length} result${rows.length !== 1 ? "s" : ""} found`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatItemTable(item) {
|
|
19
|
+
const table = new Table();
|
|
20
|
+
for (const [key, value] of Object.entries(item)) {
|
|
21
|
+
const displayValue = typeof value === "object" && value !== null
|
|
22
|
+
? JSON.stringify(value, null, 2)
|
|
23
|
+
: String(value ?? "");
|
|
24
|
+
table.push({ [key]: displayValue });
|
|
25
|
+
}
|
|
26
|
+
return table.toString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = formatTable;
|
package/cli/index.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const { Command } = require("commander");
|
|
2
|
+
const pkg = require("../package.json");
|
|
3
|
+
|
|
4
|
+
import("update-notifier")
|
|
5
|
+
.then(({ default: updateNotifier }) => updateNotifier({ pkg }).notify())
|
|
6
|
+
.catch(() => {});
|
|
7
|
+
|
|
8
|
+
const program = new Command();
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name("cisco-ise")
|
|
12
|
+
.description("CLI for Cisco ISE (Identity Services Engine)")
|
|
13
|
+
.version(pkg.version)
|
|
14
|
+
.option("--format <format>", "output format (table, json, toon, csv)", "table")
|
|
15
|
+
.option("--host <host>", "ISE hostname or IP")
|
|
16
|
+
.option("--username <user>", "ISE username")
|
|
17
|
+
.option("--password <pass>", "ISE password")
|
|
18
|
+
.option("--cluster <name>", "use a named cluster from config")
|
|
19
|
+
.option("--ppan <host>", "PAN node for ERS/OpenAPI (large deployments)")
|
|
20
|
+
.option("--pmnt <host>", "MNT node for monitoring APIs (large deployments)")
|
|
21
|
+
.option("--sponsor-user <user>", "sponsor username for guest API")
|
|
22
|
+
.option("--sponsor-password <pass>", "sponsor password for guest API")
|
|
23
|
+
.option("--insecure", "skip TLS certificate verification")
|
|
24
|
+
.option("--read-only", "block write operations")
|
|
25
|
+
.option("--dry-run", "show what would happen without executing")
|
|
26
|
+
.option("--no-audit", "disable audit logging")
|
|
27
|
+
.option("--no-cache", "bypass response cache")
|
|
28
|
+
.option("--debug", "enable debug logging");
|
|
29
|
+
|
|
30
|
+
// Commands — registered as each command file is created
|
|
31
|
+
require("./commands/config.js")(program);
|
|
32
|
+
require("./commands/endpoint.js")(program);
|
|
33
|
+
require("./commands/identity-group.js")(program);
|
|
34
|
+
require("./commands/auth-profile.js")(program);
|
|
35
|
+
require("./commands/network-device.js")(program);
|
|
36
|
+
require("./commands/guest.js")(program);
|
|
37
|
+
require("./commands/session.js")(program);
|
|
38
|
+
require("./commands/radius.js")(program);
|
|
39
|
+
require("./commands/tacacs.js")(program);
|
|
40
|
+
require("./commands/trustsec.js")(program);
|
|
41
|
+
require("./commands/internal-user.js")(program);
|
|
42
|
+
require("./commands/deployment.js")(program);
|
|
43
|
+
|
|
44
|
+
program.parse(process.argv);
|
package/cli/utils/api.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
const axios = require("axios");
|
|
2
|
+
const { XMLParser } = require("fast-xml-parser");
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const crypto = require("crypto");
|
|
6
|
+
|
|
7
|
+
const xmlParser = new XMLParser({ ignoreAttributes: false });
|
|
8
|
+
|
|
9
|
+
class IseClient {
|
|
10
|
+
constructor(conn, opts = {}) {
|
|
11
|
+
this.conn = conn;
|
|
12
|
+
this.noCache = opts.noCache || false;
|
|
13
|
+
this.debug = opts.debug || false;
|
|
14
|
+
this.dryRun = opts.dryRun || false;
|
|
15
|
+
this.configDir = process.env.CISCO_ISE_CONFIG_DIR || path.join(require("os").homedir(), ".cisco-ise");
|
|
16
|
+
this.cacheDir = path.join(this.configDir, "cache");
|
|
17
|
+
this.cacheTTL = 5 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
const httpsAgent = conn.insecure
|
|
20
|
+
? new (require("https").Agent)({ rejectUnauthorized: false })
|
|
21
|
+
: undefined;
|
|
22
|
+
|
|
23
|
+
this.axios = axios.create({
|
|
24
|
+
auth: { username: conn.username, password: conn.password },
|
|
25
|
+
httpsAgent,
|
|
26
|
+
timeout: 90000,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Sponsor axios instance for guest API (uses sponsor credentials)
|
|
30
|
+
if (conn.sponsorUser && conn.sponsorPassword) {
|
|
31
|
+
this.sponsorAxios = axios.create({
|
|
32
|
+
auth: { username: conn.sponsorUser, password: conn.sponsorPassword },
|
|
33
|
+
httpsAgent,
|
|
34
|
+
timeout: 90000,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
sponsorGet(endpoint, params = {}) {
|
|
40
|
+
if (!this.sponsorAxios) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"Guest API requires sponsor credentials. Run:\n" +
|
|
43
|
+
" cisco-ise config update <name> --sponsor-user <user> --sponsor-password <pass>"
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const url = this.ersUrl(endpoint);
|
|
47
|
+
if (this.debug) process.stderr.write(`[DEBUG] SPONSOR GET ${url}\n`);
|
|
48
|
+
return this.sponsorAxios({ method: "GET", url, headers: { Accept: "application/json" }, params })
|
|
49
|
+
.then((res) => res.data);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
sponsorPost(endpoint, body) {
|
|
53
|
+
if (!this.sponsorAxios) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"Guest API requires sponsor credentials. Run:\n" +
|
|
56
|
+
" cisco-ise config update <name> --sponsor-user <user> --sponsor-password <pass>"
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const url = this.ersUrl(endpoint);
|
|
60
|
+
if (this.dryRun) return Promise.resolve({ dryRun: true, method: "POST", url, body });
|
|
61
|
+
if (this.debug) process.stderr.write(`[DEBUG] SPONSOR POST ${url}\n`);
|
|
62
|
+
return this.sponsorAxios({ method: "POST", url, headers: { Accept: "application/json", "Content-Type": "application/json" }, data: body })
|
|
63
|
+
.then((res) => { this.invalidateCache(); return res.data; });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
sponsorPut(endpoint, body) {
|
|
67
|
+
if (!this.sponsorAxios) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
"Guest API requires sponsor credentials. Run:\n" +
|
|
70
|
+
" cisco-ise config update <name> --sponsor-user <user> --sponsor-password <pass>"
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const url = this.ersUrl(endpoint);
|
|
74
|
+
if (this.dryRun) return Promise.resolve({ dryRun: true, method: "PUT", url, body });
|
|
75
|
+
if (this.debug) process.stderr.write(`[DEBUG] SPONSOR PUT ${url}\n`);
|
|
76
|
+
return this.sponsorAxios({ method: "PUT", url, headers: { Accept: "application/json", "Content-Type": "application/json" }, data: body })
|
|
77
|
+
.then((res) => { this.invalidateCache(); return res.data; });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
sponsorDelete(endpoint) {
|
|
81
|
+
if (!this.sponsorAxios) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
"Guest API requires sponsor credentials. Run:\n" +
|
|
84
|
+
" cisco-ise config update <name> --sponsor-user <user> --sponsor-password <pass>"
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const url = this.ersUrl(endpoint);
|
|
88
|
+
if (this.dryRun) return Promise.resolve({ dryRun: true, method: "DELETE", url });
|
|
89
|
+
if (this.debug) process.stderr.write(`[DEBUG] SPONSOR DELETE ${url}\n`);
|
|
90
|
+
return this.sponsorAxios({ method: "DELETE", url, headers: { Accept: "application/json" } })
|
|
91
|
+
.then((res) => { this.invalidateCache(); return res.data; });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async sponsorPaginateAll(endpoint, params = {}, opts = {}) {
|
|
95
|
+
const limit = opts.limit || Infinity;
|
|
96
|
+
const pageSize = opts.pageSize || 100;
|
|
97
|
+
let page = opts.page || 1;
|
|
98
|
+
let all = [];
|
|
99
|
+
|
|
100
|
+
while (all.length < limit) {
|
|
101
|
+
const data = await this.sponsorGet(endpoint, { ...params, size: pageSize, page });
|
|
102
|
+
const result = data?.SearchResult;
|
|
103
|
+
if (!result?.resources?.length) break;
|
|
104
|
+
all = all.concat(result.resources);
|
|
105
|
+
if (all.length >= result.total || result.resources.length < pageSize) break;
|
|
106
|
+
page++;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const results = all.slice(0, limit === Infinity ? undefined : limit);
|
|
110
|
+
return results.map((r) => {
|
|
111
|
+
if (!r.link) return r;
|
|
112
|
+
const { link, ...rest } = r;
|
|
113
|
+
return rest;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
ersUrl(endpoint) {
|
|
118
|
+
const host = this.conn.ppan || this.conn.host;
|
|
119
|
+
return `https://${host}:9060/ers/config${endpoint}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
openApiUrl(endpoint) {
|
|
123
|
+
const host = this.conn.ppan || this.conn.host;
|
|
124
|
+
return `https://${host}/api/v1${endpoint}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
mntUrl(endpoint) {
|
|
128
|
+
const host = this.conn.pmnt || this.conn.host;
|
|
129
|
+
return `https://${host}/admin/API/mnt${endpoint}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
cacheKey(url, params = {}) {
|
|
133
|
+
const data = JSON.stringify({ url, params });
|
|
134
|
+
return crypto.createHash("md5").update(data).digest("hex");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
getCached(key) {
|
|
138
|
+
if (this.noCache) return null;
|
|
139
|
+
const file = path.join(this.cacheDir, `${key}.json`);
|
|
140
|
+
try {
|
|
141
|
+
const stat = fs.statSync(file);
|
|
142
|
+
if (Date.now() - stat.mtimeMs > this.cacheTTL) {
|
|
143
|
+
fs.unlinkSync(file);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
setCache(key, data) {
|
|
153
|
+
if (this.noCache) return;
|
|
154
|
+
fs.mkdirSync(this.cacheDir, { recursive: true });
|
|
155
|
+
fs.writeFileSync(path.join(this.cacheDir, `${key}.json`), JSON.stringify(data));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
invalidateCache(prefix) {
|
|
159
|
+
try {
|
|
160
|
+
const files = fs.readdirSync(this.cacheDir);
|
|
161
|
+
for (const file of files) {
|
|
162
|
+
if (!prefix || file.startsWith(prefix)) {
|
|
163
|
+
fs.unlinkSync(path.join(this.cacheDir, file));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} catch { /* cache dir may not exist */ }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async ersGet(endpoint, params = {}) {
|
|
170
|
+
const url = this.ersUrl(endpoint);
|
|
171
|
+
const key = this.cacheKey(url, params);
|
|
172
|
+
const cached = this.getCached(key);
|
|
173
|
+
if (cached) return cached;
|
|
174
|
+
|
|
175
|
+
const res = await this.request("GET", url, {
|
|
176
|
+
headers: { Accept: "application/json" },
|
|
177
|
+
params,
|
|
178
|
+
});
|
|
179
|
+
this.setCache(key, res.data);
|
|
180
|
+
return res.data;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async ersPaginateAll(endpoint, params = {}, opts = {}) {
|
|
184
|
+
const limit = opts.limit || Infinity;
|
|
185
|
+
const pageSize = opts.pageSize || 100;
|
|
186
|
+
let page = opts.page || 1;
|
|
187
|
+
let all = [];
|
|
188
|
+
|
|
189
|
+
while (all.length < limit) {
|
|
190
|
+
const data = await this.ersGet(endpoint, { ...params, size: pageSize, page });
|
|
191
|
+
const result = data?.SearchResult;
|
|
192
|
+
if (!result?.resources?.length) break;
|
|
193
|
+
all = all.concat(result.resources);
|
|
194
|
+
if (all.length >= result.total || result.resources.length < pageSize) break;
|
|
195
|
+
page++;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const results = all.slice(0, limit === Infinity ? undefined : limit);
|
|
199
|
+
// Strip ERS link objects (they render as [object Object] in table output)
|
|
200
|
+
return results.map((r) => {
|
|
201
|
+
if (!r.link) return r;
|
|
202
|
+
const { link, ...rest } = r;
|
|
203
|
+
return rest;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async ersPost(endpoint, body) {
|
|
208
|
+
const url = this.ersUrl(endpoint);
|
|
209
|
+
if (this.dryRun) {
|
|
210
|
+
return { dryRun: true, method: "POST", url, body };
|
|
211
|
+
}
|
|
212
|
+
const res = await this.request("POST", url, {
|
|
213
|
+
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
|
214
|
+
data: body,
|
|
215
|
+
});
|
|
216
|
+
this.invalidateCache();
|
|
217
|
+
return res.data;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async ersPut(endpoint, body) {
|
|
221
|
+
const url = this.ersUrl(endpoint);
|
|
222
|
+
if (this.dryRun) {
|
|
223
|
+
return { dryRun: true, method: "PUT", url, body };
|
|
224
|
+
}
|
|
225
|
+
const res = await this.request("PUT", url, {
|
|
226
|
+
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
|
227
|
+
data: body,
|
|
228
|
+
});
|
|
229
|
+
this.invalidateCache();
|
|
230
|
+
return res.data;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async ersDelete(endpoint) {
|
|
234
|
+
const url = this.ersUrl(endpoint);
|
|
235
|
+
if (this.dryRun) {
|
|
236
|
+
return { dryRun: true, method: "DELETE", url };
|
|
237
|
+
}
|
|
238
|
+
const res = await this.request("DELETE", url, {
|
|
239
|
+
headers: { Accept: "application/json" },
|
|
240
|
+
});
|
|
241
|
+
this.invalidateCache();
|
|
242
|
+
return res.data;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async openApiGet(endpoint, params = {}) {
|
|
246
|
+
const url = this.openApiUrl(endpoint);
|
|
247
|
+
const key = this.cacheKey(url, params);
|
|
248
|
+
const cached = this.getCached(key);
|
|
249
|
+
if (cached) return cached;
|
|
250
|
+
|
|
251
|
+
const res = await this.request("GET", url, {
|
|
252
|
+
headers: { Accept: "application/json" },
|
|
253
|
+
params,
|
|
254
|
+
});
|
|
255
|
+
this.setCache(key, res.data);
|
|
256
|
+
return res.data;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async mntGet(endpoint) {
|
|
260
|
+
const url = this.mntUrl(endpoint);
|
|
261
|
+
const key = this.cacheKey(url);
|
|
262
|
+
const cached = this.getCached(key);
|
|
263
|
+
if (cached) return cached;
|
|
264
|
+
|
|
265
|
+
const res = await this.request("GET", url, {
|
|
266
|
+
headers: { Accept: "application/xml" },
|
|
267
|
+
});
|
|
268
|
+
const parsed = xmlParser.parse(res.data);
|
|
269
|
+
this.setCache(key, parsed);
|
|
270
|
+
return parsed;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async request(method, url, config = {}) {
|
|
274
|
+
if (this.debug) {
|
|
275
|
+
process.stderr.write(`[DEBUG] ${method} ${url}\n`);
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
return await this.axios({ method, url, ...config });
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (err.response?.status === 429) {
|
|
281
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
282
|
+
const delay = Math.pow(2, attempt) * 1000;
|
|
283
|
+
process.stderr.write(`Rate limited, retrying in ${delay / 1000}s...\n`);
|
|
284
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
285
|
+
try {
|
|
286
|
+
return await this.axios({ method, url, ...config });
|
|
287
|
+
} catch (retryErr) {
|
|
288
|
+
if (retryErr.response?.status !== 429 || attempt === 3) throw retryErr;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
throw err;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = IseClient;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { getConfigDir } = require("./config.js");
|
|
4
|
+
|
|
5
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
function getAuditPath() {
|
|
8
|
+
return path.join(getConfigDir(), "audit.jsonl");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function rotateIfNeeded(auditPath) {
|
|
12
|
+
try {
|
|
13
|
+
const stats = fs.statSync(auditPath);
|
|
14
|
+
if (stats.size >= MAX_FILE_SIZE) {
|
|
15
|
+
const rotated = auditPath + ".1";
|
|
16
|
+
if (fs.existsSync(rotated)) fs.unlinkSync(rotated);
|
|
17
|
+
fs.renameSync(auditPath, rotated);
|
|
18
|
+
}
|
|
19
|
+
} catch { /* file doesn't exist yet */ }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function log(entry) {
|
|
23
|
+
const dir = getConfigDir();
|
|
24
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
25
|
+
const auditPath = getAuditPath();
|
|
26
|
+
rotateIfNeeded(auditPath);
|
|
27
|
+
fs.appendFileSync(auditPath, JSON.stringify({ timestamp: new Date().toISOString(), ...entry }) + "\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { log };
|