@knowttl/technitium-axi 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +384 -2
- package/axi-credential.schema.json +37 -0
- package/bin/technitium-axi.js +10 -0
- package/command-inventory.json +8556 -0
- package/dist/archive-contract.js +58 -0
- package/dist/catalogue.js +40 -0
- package/dist/cli.js +157 -0
- package/dist/cluster.js +129 -0
- package/dist/configuration.js +163 -0
- package/dist/credentials.js +178 -0
- package/dist/errors.js +14 -0
- package/dist/private-file.js +137 -0
- package/dist/read-contracts.js +58 -0
- package/dist/reads.js +350 -0
- package/dist/routing.js +14 -0
- package/dist/vendor.js +126 -0
- package/dist/write-contracts.js +154 -0
- package/dist/writes.js +940 -0
- package/package.json +50 -4
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ToolError } from './errors.js';
|
|
2
|
+
// Entry names and selection rules reviewed against the pinned vendor handlers.
|
|
3
|
+
export const archiveContract = {
|
|
4
|
+
sourceRevision: '0dd592aad21eced4221a9a46204cb1a299972ca0',
|
|
5
|
+
sources: {
|
|
6
|
+
'DnsWebService.cs': '1e2f492baa3113d09704589bf0109fd4c9bbc05f65204918d0d485ba8ae5753e',
|
|
7
|
+
'WebServiceClusterApi.cs': '2bb0efaa14335f9bfa74844db917021a2d2869604d1531b2e85e3105cad1e6d5',
|
|
8
|
+
'Cluster/ClusterManager.cs': '787086de7f3a2b625e788042d4f8ef2aa8d6ef27c9d235be2ffc94922d4ff496',
|
|
9
|
+
},
|
|
10
|
+
backup: {
|
|
11
|
+
authConfig: ['auth.config'], clusterConfig: ['cluster.config'],
|
|
12
|
+
webServiceSettings: ['webservice.config', '<certificate-relative-path-within-config>'],
|
|
13
|
+
dnsSettings: ['dns.config', '<certificate-relative-path-within-config>'],
|
|
14
|
+
logSettings: ['log.config'], zones: ['zones/*.zone'], allowedZones: ['allowed.config'],
|
|
15
|
+
blockedZones: ['blocked.config'], blockLists: ['blocklist.config', 'blocklists/*'],
|
|
16
|
+
apps: ['apps/**/*'], scopes: ['scopes/*.scope'], stats: ['stats/*.stat', 'stats/*.dstat'], logs: ['logs/*.log'],
|
|
17
|
+
},
|
|
18
|
+
transfer: {
|
|
19
|
+
changedFiles: ['auth.config', 'dns.config', 'allowed.config', 'blocked.config', 'blocklist.config'],
|
|
20
|
+
excludedComponents: ['clusterConfig', 'webServiceSettings', 'logSettings', 'scopes', 'stats', 'logs'],
|
|
21
|
+
excludedEntries: ['zones/*.zone', 'blocklists/*', '<certificate-relative-path-within-config>'],
|
|
22
|
+
comparison: 'file mtime > If-Modified-Since; DNSSEC key StateChangedOn > If-Modified-Since',
|
|
23
|
+
keys: 'zones/<zone-name>.keys: signed Primary members of the cluster catalog; includeZones forces eligible selected names, changed unselected keys remain eligible',
|
|
24
|
+
appEntries: { unchanged: 'apps/<name>/.exists', package: 'apps/<name>/<name>.zip', config: 'apps/<name>/dnsApp.config' },
|
|
25
|
+
appMarker: 'Emit zero-byte .exists only when neither package nor config is newer; empty and key-only worlds have no app directories',
|
|
26
|
+
response: { status: 200, contentType: 'application/zip', lastModified: 'RFC 1123 GMT response time', emptyZip: 'valid protected result' },
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
export function validateUploadArchive(bytes) {
|
|
30
|
+
const invalid = () => { throw new ToolError('UPLOAD_INVALID', 'Upload must be a bounded ZIP with safe relative entry names and no links', 'Select a valid app package or settings backup'); };
|
|
31
|
+
if (bytes.length < 22)
|
|
32
|
+
invalid();
|
|
33
|
+
let end = bytes.length - 22;
|
|
34
|
+
const minimum = Math.max(0, end - 65535);
|
|
35
|
+
while (end >= minimum && bytes.readUInt32LE(end) !== 0x06054b50)
|
|
36
|
+
end--;
|
|
37
|
+
if (end < minimum || end + 22 + bytes.readUInt16LE(end + 20) !== bytes.length || bytes.readUInt16LE(end + 4) !== 0 || bytes.readUInt16LE(end + 6) !== 0)
|
|
38
|
+
invalid();
|
|
39
|
+
const count = bytes.readUInt16LE(end + 10);
|
|
40
|
+
let offset = bytes.readUInt32LE(end + 16);
|
|
41
|
+
const centralEnd = offset + bytes.readUInt32LE(end + 12);
|
|
42
|
+
if (count !== bytes.readUInt16LE(end + 8) || centralEnd !== end)
|
|
43
|
+
invalid();
|
|
44
|
+
for (let i = 0; i < count; i++) {
|
|
45
|
+
if (offset + 46 > centralEnd || bytes.readUInt32LE(offset) !== 0x02014b50)
|
|
46
|
+
invalid();
|
|
47
|
+
const length = bytes.readUInt16LE(offset + 28);
|
|
48
|
+
const next = offset + 46 + length + bytes.readUInt16LE(offset + 30) + bytes.readUInt16LE(offset + 32);
|
|
49
|
+
if (next > centralEnd)
|
|
50
|
+
invalid();
|
|
51
|
+
const name = bytes.subarray(offset + 46, offset + 46 + length).toString('utf8');
|
|
52
|
+
if (!name || /[\\\x00-\x1f]/.test(name) || name.startsWith('/') || /^[A-Za-z]:/.test(name) || name.split('/').includes('..') || (bytes.readUInt32LE(offset + 38) >>> 16 & 0xf000) === 0xa000)
|
|
53
|
+
invalid();
|
|
54
|
+
offset = next;
|
|
55
|
+
}
|
|
56
|
+
if (offset !== centralEnd)
|
|
57
|
+
invalid();
|
|
58
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readContracts } from './read-contracts.js';
|
|
2
|
+
import { archiveContract } from './archive-contract.js';
|
|
3
|
+
import { writeContracts, writeFlags, recordFamilies } from './write-contracts.js';
|
|
4
|
+
import { settingsFields, dhcpFields, identityFields } from './configuration.js';
|
|
5
|
+
import { routing } from './routing.js';
|
|
6
|
+
export const readFlags = ['--json', '--help', '--config', '--profile', '--result-file', '--fields', '--full', '--max-bytes', '--max-requests', '--max-pages', '--max-elapsed-ms'];
|
|
7
|
+
export const commands = [
|
|
8
|
+
{ command: 'schema', description: 'Show the credential profile JSON Schema', effect: 'local', arguments: [], flags: ['--json', '--help'] },
|
|
9
|
+
{ command: 'inventory', description: 'Show the packaged command inventory', effect: 'local', arguments: [], flags: ['--json', '--help'] },
|
|
10
|
+
{ command: 'status', description: 'Read the selected server status without sending a credential', effect: 'read', operation: 'OP-001', arguments: [], flags: ['--json', '--help', '--config', '--profile'] },
|
|
11
|
+
{ command: 'user session get', description: 'Read the selected session with a redacted projection and optional protected result file', effect: 'read', operation: 'OP-006', arguments: [], flags: ['--json', '--help', '--config', '--profile', '--result-file'] },
|
|
12
|
+
{ command: 'auth status', description: 'Verify the selected credential and report its source without values', effect: 'local', arguments: [], flags: ['--json', '--help', '--config', '--profile'] },
|
|
13
|
+
...['login', 'logout'].map(action => ({ command: `auth ${action}`, description: `Alias for user ${action}`, effect: 'write', arguments: [], flags: writeFlags })),
|
|
14
|
+
{ command: 'profile list', description: 'List profile names from the selected config', effect: 'local', arguments: [], flags: ['--json', '--help', '--config'] },
|
|
15
|
+
{ command: 'config path', description: 'Show the selected config path', effect: 'local', arguments: [], flags: ['--json', '--help', '--config'] },
|
|
16
|
+
{ command: 'config import', description: 'Validate and atomically install a config containing SecretRefs', effect: 'local', arguments: [], flags: ['--json', '--help', '--config', '--input-file'] },
|
|
17
|
+
{ command: 'doctor', description: 'Verify credential protection and perform one bounded session read', effect: 'read', arguments: [], flags: ['--json', '--help', '--config', '--profile'] },
|
|
18
|
+
...readContracts.filter(item => !['OP-001', 'OP-006'].includes(item.id)).map(item => ({
|
|
19
|
+
command: item.command, description: `Read ${item.command}`, effect: 'read', operation: item.id, routing: routing(item.id),
|
|
20
|
+
arguments: item.parameters, flags: [...readFlags, ...item.parameters.map(parameter => parameter.flag)],
|
|
21
|
+
...(['OP-084', 'OP-125'].includes(item.id) ? { archive: archiveContract } : {}),
|
|
22
|
+
})),
|
|
23
|
+
...writeContracts.map(item => ({ command: item.command, description: `Preview or execute ${item.command}`, effect: item.id === 'OP-077' ? 'conditional' : 'write', operation: item.id,
|
|
24
|
+
arguments: [], flags: [...writeFlags, ...(['OP-119', 'OP-127'].includes(item.id) ? ['--reconcile-profile'] : [])], routing: routing(item.id), input: { envelope: ['parameters', 'baseline', 'upload'], fields: item.fields,
|
|
25
|
+
required: item.required, secrets: item.secrets ?? [], baseline: item.baselinePath ?? null,
|
|
26
|
+
...(['OP-002', 'OP-003', 'OP-004', 'OP-009', 'OP-099'].includes(item.id) ? { resultPolicy: { classification: 'confidential-fields', ordinaryOutput: 'metadata and approved projection only', localResult: item.id === 'OP-002' ? 'issued Standard session stored privately; optional exact protected result' : 'required protected result file', hostedResult: 'caller and credential revision bound protected custody' } } : {}),
|
|
27
|
+
...(['OP-022', 'OP-023'].includes(item.id) ? { upload: { file: 'protected absolute path', format: item.id === 'OP-023' ? ['multipart (default)', 'text'] : ['multipart'] } } : {}),
|
|
28
|
+
...(['OP-072', 'OP-073', 'OP-085'].includes(item.id) ? { upload: { file: 'required protected absolute ZIP path', transport: 'multipart first binary file; parameters in query string' } } : {}),
|
|
29
|
+
...(item.id === 'OP-076' ? { config: 'File SecretRef containing the exact opaque config string; never printed' } : {}),
|
|
30
|
+
...(item.id === 'OP-029' ? { target: 'Exactly one of zone or nonempty zones array' } : {}),
|
|
31
|
+
...(item.id === 'OP-034' ? { permissions: { userPermissions: ['username', 'canView', 'canModify', 'canDelete'], groupPermissions: ['name', 'canView', 'canModify', 'canDelete'], shape: 'Array of rows; permission flags are booleans; an empty user array clears user permissions; group replacements retain Administrators and DNS Administrators with all permissions' } } : {}),
|
|
32
|
+
...(item.id === 'OP-113' ? { permissions: { userPermissions: ['username', 'canView', 'canModify', 'canDelete'], groupPermissions: ['name', 'canView', 'canModify', 'canDelete'], shape: 'Array of rows with boolean flags; empty arrays clear permissions; vendor retains Administrators, plus DNS Administrators for Zones or DHCP Administrators for DhcpServer; section writes require the primary' } } : {}),
|
|
33
|
+
...(item.id === 'OP-080' ? { configuration: settingsFields, encoding: ['form', 'json'], secretRows: 'tsigKeys rows require a SecretRef for sharedSecret' } : {}),
|
|
34
|
+
...(item.id === 'OP-092' ? { configuration: dhcpFields, target: 'Existing scope; baseline.newName is the current name when renaming; collections replace the selected collection' } : {}),
|
|
35
|
+
...(['OP-115', 'OP-142', 'OP-143'].includes(item.id) ? { configuration: Object.fromEntries(item.fields.map(key => [key, identityFields[key]])), target: item.id === 'OP-143' ? 'Outbound LDAP bind using stored fields and explicit overrides; requires exact OP-143 write grant' : 'Only supplied fields change; scopes and group maps replace their selected collection', ...(item.id === 'OP-115' ? { scopesEncoding: 'pipe-delimited string array' } : {}) } : {}),
|
|
36
|
+
...(item.command.startsWith('dhcp lease ') ? { target: 'Exactly one of clientIdentifier or hardwareAddress' } : {}),
|
|
37
|
+
...(item.id === 'OP-077' ? { capabilities: ['OP-077.resolve', 'OP-077.import'] } : {}),
|
|
38
|
+
...(['OP-049', 'OP-051', 'OP-052'].includes(item.id) ? { recordFamilies } : {}) } })),
|
|
39
|
+
];
|
|
40
|
+
export const inventory = { version: 1, tool: 'technitium-axi', commands, dhcpFields };
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { encode } from '@toon-format/toon';
|
|
6
|
+
import { commands, inventory } from './catalogue.js';
|
|
7
|
+
import { acquisitionKey, configPath, loadConfig, selectProfile } from './credentials.js';
|
|
8
|
+
import { ToolError } from './errors.js';
|
|
9
|
+
import { PrivateFiles } from './private-file.js';
|
|
10
|
+
import { Redactor, saveResult, vendorRead } from './vendor.js';
|
|
11
|
+
import { readContracts } from './read-contracts.js';
|
|
12
|
+
import { readInputs, readOperation } from './reads.js';
|
|
13
|
+
import { writeContracts } from './write-contracts.js';
|
|
14
|
+
import { writeInput, writeOperation } from './writes.js';
|
|
15
|
+
const help = {
|
|
16
|
+
description: 'Manage Technitium DNS Server through one selected credential profile', commands,
|
|
17
|
+
flags: ['--help', '--version', '-v', '-V', '--json', '--config', '--profile'],
|
|
18
|
+
examples: ['technitium-axi status --profile <name>', 'technitium-axi schema --json', 'technitium-axi user session get'],
|
|
19
|
+
};
|
|
20
|
+
export async function main(args) {
|
|
21
|
+
const redactor = new Redactor();
|
|
22
|
+
const json = args.includes('--json');
|
|
23
|
+
const print = (value) => { console.log(redactor.text(json ? JSON.stringify(value) : encode(value))); };
|
|
24
|
+
try {
|
|
25
|
+
const options = {};
|
|
26
|
+
const readOptions = {};
|
|
27
|
+
const valueFlags = new Set(['--config', '--profile', '--result-file', '--input-file', '--fields', '--max-bytes', '--max-requests', '--max-pages', '--max-elapsed-ms',
|
|
28
|
+
'--confirm', '--reconcile', '--reconcile-profile',
|
|
29
|
+
...readContracts.flatMap(item => item.parameters.map(parameter => parameter.flag))]);
|
|
30
|
+
const positional = [];
|
|
31
|
+
const flags = new Set();
|
|
32
|
+
for (let i = 0; i < args.length; i++) {
|
|
33
|
+
const arg = args[i];
|
|
34
|
+
if (!arg.startsWith('-')) {
|
|
35
|
+
positional.push(arg);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (flags.has(arg))
|
|
39
|
+
throw new ToolError('USAGE', 'Repeated flag', 'Provide each flag once');
|
|
40
|
+
flags.add(arg);
|
|
41
|
+
if (valueFlags.has(arg)) {
|
|
42
|
+
const value = args[++i];
|
|
43
|
+
if (value === undefined || value.startsWith('-') || (value === '' && ['--config', '--profile', '--result-file', '--input-file'].includes(arg)))
|
|
44
|
+
throw new ToolError('USAGE', 'Flag requires a value', `Use ${arg} <value>`);
|
|
45
|
+
if (arg === '--config')
|
|
46
|
+
options.config = value;
|
|
47
|
+
else if (arg === '--profile')
|
|
48
|
+
options.profile = value;
|
|
49
|
+
else if (arg === '--result-file')
|
|
50
|
+
options.resultFile = value;
|
|
51
|
+
else if (arg === '--input-file')
|
|
52
|
+
options.inputFile = value;
|
|
53
|
+
else
|
|
54
|
+
readOptions[arg] = value;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const requestedCommand = positional.join(' ');
|
|
58
|
+
const command = requestedCommand === 'auth login' ? 'user login' : requestedCommand === 'auth logout' ? 'user logout' : requestedCommand;
|
|
59
|
+
// A stray value after an unknown flag extends the positionals, so match the longest command prefix to keep the command's own flag list in the error.
|
|
60
|
+
const selected = commands.filter(item => requestedCommand === item.command || requestedCommand.startsWith(`${item.command} `)).sort((a, b) => b.command.length - a.command.length)[0];
|
|
61
|
+
const allowed = selected ? [...selected.flags, '--version', '-v', '-V'] : help.flags;
|
|
62
|
+
if ([...flags].some(flag => !allowed.includes(flag)))
|
|
63
|
+
throw new ToolError('USAGE', 'Unknown or prohibited flag', `Valid flags: ${allowed.join(', ')}`);
|
|
64
|
+
if (command && selected?.command !== requestedCommand)
|
|
65
|
+
throw new ToolError('USAGE', 'Unknown command or unexpected argument', 'Run technitium-axi --help');
|
|
66
|
+
if (args.some(arg => ['--version', '-v', '-V'].includes(arg))) {
|
|
67
|
+
console.log(JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (flags.has('--help')) {
|
|
71
|
+
print(selected ? { command: selected.command, description: selected.description, flags: selected.flags, arguments: selected.arguments,
|
|
72
|
+
examples: [`technitium-axi ${selected.command}`, `technitium-axi ${selected.command} --json`] } : help);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (command === 'schema') {
|
|
76
|
+
print(JSON.parse(await readFile(new URL('../axi-credential.schema.json', import.meta.url), 'utf8')));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (command === 'inventory') {
|
|
80
|
+
print(inventory);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (command === 'config path') {
|
|
84
|
+
print({ config: configPath(options.config) });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (command === 'config import') {
|
|
88
|
+
if (!options.inputFile)
|
|
89
|
+
throw new ToolError('USAGE', 'Config input is required', 'Use config import --input-file <file>');
|
|
90
|
+
const config = await loadConfig(resolve(options.inputFile), true);
|
|
91
|
+
await new PrivateFiles().write(configPath(options.config), JSON.stringify(config));
|
|
92
|
+
print({ config: configPath(options.config), profiles: Object.keys(config.profiles).length });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (command === 'profile list') {
|
|
96
|
+
const config = await loadConfig(configPath(options.config), options.config !== undefined || process.env.TECHNITIUM_AXI_CONFIG !== undefined);
|
|
97
|
+
const profiles = Object.keys(config.profiles).sort().map(name => ({ name, default: name === config.defaultProfile }));
|
|
98
|
+
print({ count: profiles.length, profiles, help: ['Run technitium-axi auth status --profile <name>'] });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (options.resultFile && !isAbsolute(options.resultFile))
|
|
102
|
+
throw new ToolError('USAGE', 'Result file must be absolute', 'Use --result-file <absolute-path>');
|
|
103
|
+
const readContract = readContracts.find(item => item.command === command);
|
|
104
|
+
if (readContract)
|
|
105
|
+
readInputs(readContract, readOptions);
|
|
106
|
+
const writeContract = writeContracts.find(item => item.command === command);
|
|
107
|
+
const input = writeContract ? await writeInput(writeContract, options.inputFile) : undefined;
|
|
108
|
+
if (writeContract && ['OP-003', 'OP-004', 'OP-009', 'OP-099'].includes(writeContract.id) && flags.has('--execute') && !options.resultFile)
|
|
109
|
+
throw new ToolError('USAGE', 'A protected result destination is required', 'Use --result-file <absolute-path>');
|
|
110
|
+
if (writeContract && options.resultFile && flags.has('--execute'))
|
|
111
|
+
await new PrivateFiles().destination(options.resultFile);
|
|
112
|
+
const credential = await selectProfile(options);
|
|
113
|
+
let reconciliation;
|
|
114
|
+
if (readOptions['--reconcile-profile']) {
|
|
115
|
+
if (!readOptions['--reconcile'] || flags.has('--execute') || !['OP-119', 'OP-127'].includes(writeContract?.id ?? ''))
|
|
116
|
+
throw new ToolError('USAGE', 'A replacement profile is supported only when reconciling cluster init or init-join', 'Supply --reconcile <audit-id> and --reconcile-profile <trusted-profile>');
|
|
117
|
+
const replacement = await selectProfile({ ...options, profile: readOptions['--reconcile-profile'] });
|
|
118
|
+
const replacementToken = await replacement.token();
|
|
119
|
+
redactor.add(replacementToken);
|
|
120
|
+
reconciliation = { profile: replacement.profile, token: replacementToken, name: replacement.name };
|
|
121
|
+
}
|
|
122
|
+
let token;
|
|
123
|
+
const acquisition = writeContract?.id === 'OP-002' || writeContract?.id === 'OP-003' && input?.parameters.pass !== undefined;
|
|
124
|
+
if (acquisition)
|
|
125
|
+
token = await acquisitionKey();
|
|
126
|
+
else if (command && command !== 'status') {
|
|
127
|
+
token = await credential.token();
|
|
128
|
+
redactor.add(token);
|
|
129
|
+
}
|
|
130
|
+
if (command === 'auth status') {
|
|
131
|
+
print({ profile: credential.name, source: credential.source, origin: credential.profile.origin,
|
|
132
|
+
tokenKind: credential.profile.tokenKind, tokenSource: credential.tokenSource });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const result = writeContract && input ? await writeOperation(writeContract, input, credential.profile, credential.name, token, { execute: flags.has('--execute'), ...(readOptions['--confirm'] ? { confirm: readOptions['--confirm'] } : {}),
|
|
136
|
+
...(readOptions['--reconcile'] ? { reconcile: readOptions['--reconcile'] } : {}), ...(reconciliation ? { reconciliation } : {}) }, redactor)
|
|
137
|
+
: readContract && !['OP-001', 'OP-006'].includes(readContract.id)
|
|
138
|
+
? await readOperation(credential.profile, command, token, readOptions, flags.has('--full'))
|
|
139
|
+
: await vendorRead(credential.profile, command === '' || command === 'status' ? 'status' : 'user session get', token);
|
|
140
|
+
if ('secret' in result && typeof result.secret === 'string')
|
|
141
|
+
redactor.add(result.secret);
|
|
142
|
+
let reference;
|
|
143
|
+
if (options.resultFile) {
|
|
144
|
+
await saveResult(options.resultFile, result.bytes);
|
|
145
|
+
reference = { path: options.resultFile, bytes: result.bytes.length, sha256: createHash('sha256').update(result.bytes).digest('hex') };
|
|
146
|
+
}
|
|
147
|
+
const executable = resolve(process.argv[1] ?? 'bin/technitium-axi.js');
|
|
148
|
+
const home = homedir();
|
|
149
|
+
print({ ...(command === '' ? { bin: executable.startsWith(`${home}/`) ? `~${executable.slice(home.length)}` : executable, description: help.description } : {}),
|
|
150
|
+
...result.projection, ...(reference ? { result: reference } : {}) });
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
const failure = error instanceof ToolError ? error : new ToolError('CONFIG_INVALID', 'Unable to read the selected configuration or protected file', 'Check config path, SecretRef, ownership and file permissions');
|
|
154
|
+
print({ error: { code: failure.code, message: failure.message }, help: [failure.hint] });
|
|
155
|
+
process.exitCode = failure.exitCode;
|
|
156
|
+
}
|
|
157
|
+
}
|
package/dist/cluster.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { X509Certificate } from 'node:crypto';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import { ToolError } from './errors.js';
|
|
4
|
+
import { vendorRequest, checkStatus } from './vendor.js';
|
|
5
|
+
export const clusterTimers = ['heartbeatRefreshIntervalSeconds', 'heartbeatRetryIntervalSeconds', 'configRefreshIntervalSeconds', 'configRetryIntervalSeconds'];
|
|
6
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
const fail = (message) => { throw new ToolError('USAGE', message, 'Use the cluster input contract shown in inventory'); };
|
|
8
|
+
export function validateCluster(parameters) {
|
|
9
|
+
for (const [key, value] of Object.entries(parameters)) {
|
|
10
|
+
if (key.endsWith('IpAddresses') || key === 'ipAddresses') {
|
|
11
|
+
if (!Array.isArray(value) || value.length === 0 || value.some(ip => typeof ip !== 'string' || !isIP(ip)))
|
|
12
|
+
fail(`${key} requires a nonempty array of IP addresses`);
|
|
13
|
+
}
|
|
14
|
+
else if (key.endsWith('NodeId')) {
|
|
15
|
+
if (!Number.isInteger(value) || Number(value) < -2147483648 || Number(value) > 2147483647)
|
|
16
|
+
fail(`${key} requires a signed 32-bit integer`);
|
|
17
|
+
}
|
|
18
|
+
else if (key.endsWith('NodeUrl')) {
|
|
19
|
+
let url;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(String(value));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
fail(`${key} requires an HTTPS URL`);
|
|
25
|
+
}
|
|
26
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/')
|
|
27
|
+
fail(`${key} requires an HTTPS origin without credentials`);
|
|
28
|
+
}
|
|
29
|
+
else if (key === 'primaryNodeIpAddress' && (typeof value !== 'string' || !isIP(value)))
|
|
30
|
+
fail('primaryNodeIpAddress requires an IP address');
|
|
31
|
+
else if (key === 'secondaryNodeCertificate') {
|
|
32
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+={0,2}$/.test(value))
|
|
33
|
+
fail('secondaryNodeCertificate requires a base64url DER certificate');
|
|
34
|
+
try {
|
|
35
|
+
new X509Certificate(Buffer.from(value, 'base64url'));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
fail('secondaryNodeCertificate requires a base64url DER certificate');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else if (clusterTimers.includes(key)) {
|
|
42
|
+
const min = key.startsWith('heartbeat') ? 10 : 30;
|
|
43
|
+
const max = key.startsWith('heartbeat') ? 300 : 3600;
|
|
44
|
+
if (!Number.isInteger(value) || Number(value) < min || Number(value) > max)
|
|
45
|
+
fail(`${key} must be between ${min} and ${max}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function clusterState(profile, token, node) {
|
|
50
|
+
const body = node === undefined ? '' : new URLSearchParams({ node: String(node) }).toString();
|
|
51
|
+
const result = await vendorRequest(profile, '/api/admin/cluster/state', body, token, {}, { bytes: 1024 * 1024, elapsedMs: 10000 });
|
|
52
|
+
if (result.status !== 200 || result.contentType !== 'application/json')
|
|
53
|
+
throw new ToolError('UPSTREAM_INVALID', 'Cluster state read failed', 'Read cluster state using an explicitly trusted profile', 1);
|
|
54
|
+
const value = JSON.parse(result.bytes.toString());
|
|
55
|
+
checkStatus(value);
|
|
56
|
+
const state = value.response;
|
|
57
|
+
if (!object(state) || typeof state.clusterInitialized !== 'boolean' || state.clusterInitialized && (!Array.isArray(state.clusterNodes) || state.clusterNodes.some(row => !object(row) || !Number.isInteger(row.id) || typeof row.name !== 'string' || typeof row.url !== 'string' || !['Primary', 'Secondary'].includes(String(row.type)) || typeof row.state !== 'string' || !Array.isArray(row.ipAddresses) || row.ipAddresses.some(ip => typeof ip !== 'string' || !isIP(ip))))) {
|
|
58
|
+
throw new ToolError('UPSTREAM_INVALID', 'Invalid cluster state', 'Check the selected server API version', 1);
|
|
59
|
+
}
|
|
60
|
+
// Heartbeats and synchronization timestamps change independently of the topology being approved.
|
|
61
|
+
return { clusterInitialized: state.clusterInitialized,
|
|
62
|
+
...(state.clusterInitialized ? { clusterDomain: String(state.clusterDomain), ...Object.fromEntries(clusterTimers.map(key => [key, state[key]])) } : {}),
|
|
63
|
+
clusterNodes: state.clusterInitialized ? state.clusterNodes.map(row => ({ id: row.id, name: row.name, url: row.url, ipAddresses: row.ipAddresses, type: row.type, state: row.state === 'Self' ? 'Self' : 'Remote' })).sort((a, b) => a.id - b.id) : [] };
|
|
64
|
+
}
|
|
65
|
+
export function clusterPreview(id, p, before) {
|
|
66
|
+
const effects = {
|
|
67
|
+
'OP-119': 'Initialize the selected node as primary; TLS listeners and identity may change',
|
|
68
|
+
'OP-120': 'Delete selected primary cluster configuration; remote nodes are not reset',
|
|
69
|
+
'OP-121': 'Add secondary membership and notify replicated configuration',
|
|
70
|
+
'OP-122': 'Ask the selected secondary to leave; remote completion requires a separate trusted read',
|
|
71
|
+
'OP-123': 'Delete primary membership only; the removed secondary is not reset',
|
|
72
|
+
'OP-124': 'Replace selected secondary address and certificate; notify replicated configuration',
|
|
73
|
+
'OP-126': 'Change cluster timers; omitted timers remain unchanged',
|
|
74
|
+
'OP-127': 'Join selected node as secondary and replace replicated configuration; TLS identity may change',
|
|
75
|
+
'OP-128': 'Leave selected secondary; force can leave stale primary membership',
|
|
76
|
+
'OP-129': 'Update primary identity and schedule configuration synchronization',
|
|
77
|
+
'OP-130': 'Trigger asynchronous configuration and cluster secondary zone resync',
|
|
78
|
+
'OP-131': 'Update primary address on selected secondary; replacement origin is not automatically trusted',
|
|
79
|
+
'OP-132': 'Promote selected secondary and remove old primary membership; force skips old primary synchronization and deletion',
|
|
80
|
+
'OP-133': 'Update selected node addresses; TLS trust remains unchanged',
|
|
81
|
+
};
|
|
82
|
+
return { scope: p.node === undefined ? 'selected node' : 'named node via vendor proxy', effects: effects[id],
|
|
83
|
+
affectedNodes: before.clusterNodes.map(({ id, name, type }) => ({ id, name, type })),
|
|
84
|
+
completion: ['OP-129', 'OP-130'].includes(id) ? 'scheduled, not synchronized' : 'selected-node state only',
|
|
85
|
+
...(id === 'OP-127' ? { primaryNodeUrl: p.primaryNodeUrl, ignoreCertificateErrors: p.ignoreCertificateErrors ?? false } : {}),
|
|
86
|
+
...(p.forceLeave || p.forceDeletePrimary || p.forceDelete ? { uncertainty: 'Remote nodes may retain cluster configuration; verify them with explicit trusted profiles' } : {}) };
|
|
87
|
+
}
|
|
88
|
+
export function clusterMatches(id, p, before, after) {
|
|
89
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
90
|
+
const self = after.clusterNodes.find(row => row.state === 'Self');
|
|
91
|
+
const oldSelf = before.clusterNodes.find(row => row.state === 'Self');
|
|
92
|
+
const primary = after.clusterNodes.find(row => row.type === 'Primary');
|
|
93
|
+
const oldPrimary = before.clusterNodes.find(row => row.type === 'Primary');
|
|
94
|
+
if (['OP-120', 'OP-128'].includes(id))
|
|
95
|
+
return !after.clusterInitialized;
|
|
96
|
+
if (!after.clusterInitialized)
|
|
97
|
+
return false;
|
|
98
|
+
if (id === 'OP-119')
|
|
99
|
+
return after.clusterDomain === String(p.clusterDomain).replace(/\.+$/, '') && self?.type === 'Primary' && same(self.ipAddresses, p.primaryNodeIpAddresses);
|
|
100
|
+
if (id === 'OP-127')
|
|
101
|
+
return self?.type === 'Secondary' && same(self.ipAddresses, p.secondaryNodeIpAddresses) && primary?.url === p.primaryNodeUrl;
|
|
102
|
+
if (after.clusterDomain !== before.clusterDomain)
|
|
103
|
+
return false;
|
|
104
|
+
let expected = structuredClone(before.clusterNodes);
|
|
105
|
+
if (['OP-122', 'OP-123'].includes(id))
|
|
106
|
+
expected = expected.filter(row => row.id !== p.secondaryNodeId);
|
|
107
|
+
else if (['OP-121', 'OP-124'].includes(id)) {
|
|
108
|
+
const selected = after.clusterNodes.find(row => row.id === p.secondaryNodeId);
|
|
109
|
+
if (!selected || selected.type !== 'Secondary' || selected.url !== p.secondaryNodeUrl || !same(selected.ipAddresses, p.secondaryNodeIpAddresses))
|
|
110
|
+
return false;
|
|
111
|
+
expected = [...expected.filter(row => row.id !== p.secondaryNodeId), selected].sort((a, b) => a.id - b.id);
|
|
112
|
+
}
|
|
113
|
+
else if (['OP-129', 'OP-131'].includes(id)) {
|
|
114
|
+
const selected = id === 'OP-129' && Number(p.primaryNodeId) >= 0 ? expected.find(row => row.id === p.primaryNodeId) : oldPrimary;
|
|
115
|
+
if (!oldPrimary || !selected || selected.state === 'Self')
|
|
116
|
+
return false;
|
|
117
|
+
if (selected.id !== oldPrimary.id)
|
|
118
|
+
expected = expected.filter(row => row.id !== oldPrimary.id);
|
|
119
|
+
expected = expected.map(row => row.id === selected.id ? { ...row, name: new URL(String(p.primaryNodeUrl)).hostname.toLowerCase(), type: 'Primary', url: String(p.primaryNodeUrl), ipAddresses: p.primaryNodeIpAddresses ?? primary?.ipAddresses } : row);
|
|
120
|
+
}
|
|
121
|
+
else if (id === 'OP-132') {
|
|
122
|
+
if (!oldSelf || !oldPrimary || oldSelf.type !== 'Secondary')
|
|
123
|
+
return false;
|
|
124
|
+
expected = expected.filter(row => row.id !== oldPrimary.id).map(row => row.id === oldSelf.id ? { ...row, type: 'Primary' } : row);
|
|
125
|
+
}
|
|
126
|
+
else if (id === 'OP-133')
|
|
127
|
+
expected = expected.map(row => row.state === 'Self' ? { ...row, ipAddresses: p.ipAddresses } : row);
|
|
128
|
+
return same(after.clusterNodes, expected) && clusterTimers.every(key => after[key] === (id === 'OP-126' ? p[key] ?? before[key] : before[key]));
|
|
129
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { ToolError } from './errors.js';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { isIP } from 'node:net';
|
|
4
|
+
const words = (text) => text.split(' ');
|
|
5
|
+
const group = (type, text) => Object.fromEntries(words(text).map(key => [key, { type }]));
|
|
6
|
+
// Reviewed against DNS Server 0dd592aad21eced4221a9a46204cb1a299972ca0.
|
|
7
|
+
export const settingsFields = {
|
|
8
|
+
...group('string', 'dnsServerDomain defaultResponsiblePerson ipv6Mode eDnsClientSubnetIpv4Override eDnsClientSubnetIpv6Override webServiceHttpUnixSocket webServiceTlsUnixSocket webServiceTlsCertificatePath webServiceRealIpHeader webServiceCspFrameAncestorsHeader dnsOverHttpUnixSocket dnsOverHttpsUnixSocket dnsTlsCertificatePath dnsOverHttpRealIpHeader recursion blockingType proxyType proxyAddress proxyUsername forwarderProtocol loggingType logFolder'),
|
|
9
|
+
...group('boolean', 'useSoaSerialDateScheme dnsServerEnableCheckForUpdate dnsAppsEnableAutomaticUpdate enableUdpSocketPool dnssecValidation eDnsClientSubnet webServiceEnableHttpUnixSocket webServiceEnableTlsUnixSocket webServiceEnableTls webServiceEnableHttp3 webServiceHttpToTlsRedirect webServiceUseSelfSignedTlsCertificate enableEDnsClientSubnetSourceAddress enableDnsOverUdpProxy enableDnsOverTcpProxy enableDnsOverHttp enableDnsOverHttpUnixSocket enableDnsOverHttpsUnixSocket enableDnsOverTls enableDnsOverHttps enableDnsOverHttp3 enableDnsOverQuic enableDnsOverHttpHelpRedirect randomizeName qnameMinimization locallyServedDnsZones saveCache serveStale enableCachePrefetch enableBlocking allowTxtBlockingReport concurrentForwarding enableLogging ignoreResolverLogs logQueries noStackTrace useLocalTime enableInMemoryStats preferIPv6'),
|
|
10
|
+
...group('integer', 'defaultRecordTtl defaultNsRecordTtl defaultSoaRecordTtl minSoaRefresh minSoaRetry udpPayloadSize eDnsClientSubnetIPv4PrefixLength eDnsClientSubnetIPv6PrefixLength qpmLimitSampleMinutes qpmLimitUdpTruncationPercentage clientTimeout tcpSendTimeout tcpReceiveTimeout quicIdleTimeout quicMaxInboundStreams listenBacklog udpSendBufferSizeKB udpReceiveBufferSizeKB maxConcurrentResolutionsPerCore webServiceHttpPort webServiceTlsPort dnsOverUdpProxyPort dnsOverTcpProxyPort dnsOverHttpPort dnsOverTlsPort dnsOverHttpsPort dnsOverQuicPort resolverRetries resolverTimeout resolverConcurrency resolverMaxStackCount serveStaleTtl serveStaleAnswerTtl serveStaleResetTtl serveStaleMaxWaitTime cacheMaximumEntries cacheMinimumRecordTtl cacheMaximumRecordTtl cacheNegativeRecordTtl cacheFailureRecordTtl cachePrefetchEligibility cachePrefetchTrigger blockingAnswerTtl blockListUpdateIntervalHours proxyPort forwarderRetries forwarderTimeout forwarderConcurrency maxLogFileDays maxStatFileDays'),
|
|
11
|
+
...group('list', 'dnsServerLocalEndPoints dnsServerIPv4SourceAddresses dnsServerIPv6SourceAddresses zoneTransferAllowedNetworks notifyAllowedNetworks qpmLimitBypassList webServiceLocalAddresses webServiceReverseProxyAddresses dnsReverseProxyNetworkACL reverseProxyNetworkACL recursionNetworkACL blockingBypassList customBlockingAddresses blockListUrls proxyBypass forwarders'),
|
|
12
|
+
socketPoolExcludedPorts: { type: 'ports' },
|
|
13
|
+
qpmPrefixLimitsIPv4: { type: 'rows', columns: ['prefix', 'udpLimit', 'tcpLimit'] },
|
|
14
|
+
qpmPrefixLimitsIPv6: { type: 'rows', columns: ['prefix', 'udpLimit', 'tcpLimit'] },
|
|
15
|
+
tsigKeys: { type: 'rows', columns: ['keyName', 'sharedSecret', 'algorithmName'] },
|
|
16
|
+
webServiceTlsCertificatePassword: { type: 'string', secret: true },
|
|
17
|
+
dnsTlsCertificatePassword: { type: 'string', secret: true },
|
|
18
|
+
proxyPassword: { type: 'string', secret: true },
|
|
19
|
+
};
|
|
20
|
+
for (const key of ['Type', 'Address', 'Port', 'Username', 'Password', 'Bypass'])
|
|
21
|
+
settingsFields[`proxy${key}`].read = `proxy.${key === 'Bypass' ? 'bypass' : key.toLowerCase()}`;
|
|
22
|
+
settingsFields.reverseProxyNetworkACL.read = 'dnsReverseProxyNetworkACL';
|
|
23
|
+
export const dhcpFields = {
|
|
24
|
+
...group('string', 'name newName startingAddress endingAddress subnetMask domainName serverAddress serverHostName bootFileName routerAddress'),
|
|
25
|
+
...group('integer', 'leaseTimeDays leaseTimeHours leaseTimeMinutes offerDelayTime pingCheckTimeout pingCheckRetries dnsTtl'),
|
|
26
|
+
...group('boolean', 'pingCheckEnabled dnsUpdates dnsOverwriteForDynamicLease useThisDnsServer allowOnlyReservedLeases blockLocallyAdministeredMacAddresses ignoreClientIdentifierOption'),
|
|
27
|
+
...group('list', 'domainSearchList dnsServers winsServers ntpServers ntpServerDomainNames capwapAcIpAddresses tftpServerAddresses'),
|
|
28
|
+
staticRoutes: { type: 'rows', columns: ['destination', 'subnetMask', 'router'] },
|
|
29
|
+
vendorInfo: { type: 'rows', columns: ['identifier', 'information'] },
|
|
30
|
+
genericOptions: { type: 'rows', columns: ['code', 'value'] },
|
|
31
|
+
exclusions: { type: 'rows', columns: ['startingAddress', 'endingAddress'] },
|
|
32
|
+
reservedLeases: { type: 'rows', columns: ['hostName', 'hardwareAddress', 'address', 'comments'] },
|
|
33
|
+
};
|
|
34
|
+
export const identityFields = {
|
|
35
|
+
...group('string', 'ssoAuthority ssoClientId ssoMetadataAddress ldapServer ldapSslOption ldapBindUsername ldapSearchBase ldapUserSearchFilter ldapGroupAttribute'),
|
|
36
|
+
...group('boolean', 'ssoEnabled ssoAllowSignup ssoAllowSignupOnlyForMappedUsers ldapEnabled ldapIgnoreSslErrors ldapAllowSignup ldapAllowSignupOnlyForMappedUsers'),
|
|
37
|
+
ldapPort: { type: 'integer' },
|
|
38
|
+
ssoScopes: { type: 'list' },
|
|
39
|
+
ssoGroupMap: { type: 'rows', columns: ['remoteGroup', 'localGroup'] },
|
|
40
|
+
ldapGroupMap: { type: 'rows', columns: ['remoteGroup', 'localGroup'] },
|
|
41
|
+
ssoClientSecret: { type: 'string', secret: true },
|
|
42
|
+
ldapBindPassword: { type: 'string', secret: true },
|
|
43
|
+
};
|
|
44
|
+
export function validateIdentity(key, value, resolved = false) {
|
|
45
|
+
validateConfiguration(identityFields[key], value, resolved);
|
|
46
|
+
if (key === 'ssoScopes' && value.some(scope => /[|]/.test(scope)))
|
|
47
|
+
invalid();
|
|
48
|
+
if (key === 'ldapPort' && Number(value) > 65535)
|
|
49
|
+
invalid();
|
|
50
|
+
if (key === 'ldapSslOption' && !['None', 'LDAPS', 'StartTLS'].includes(String(value)))
|
|
51
|
+
invalid();
|
|
52
|
+
if (identityFields[key].secret && resolved && value === '************')
|
|
53
|
+
invalid();
|
|
54
|
+
}
|
|
55
|
+
export function dhcpValue(key, value) {
|
|
56
|
+
const field = dhcpFields[key];
|
|
57
|
+
if (value == null && ['list', 'rows'].includes(field.type))
|
|
58
|
+
return [];
|
|
59
|
+
if (value == null && field.type === 'string')
|
|
60
|
+
return '';
|
|
61
|
+
if (['genericOptions', 'vendorInfo'].includes(key) && Array.isArray(value)) {
|
|
62
|
+
const column = key === 'genericOptions' ? 'value' : 'information';
|
|
63
|
+
return value.map(row => ({ ...row, [column]: typeof row?.[column] === 'string' && /^(?:[\da-fA-F]{2})+$/.test(row[column].replace(/:/g, ''))
|
|
64
|
+
? row[column].replace(/:/g, '').toUpperCase().match(/../g).join(':') : row?.[column] }));
|
|
65
|
+
}
|
|
66
|
+
if (key === 'reservedLeases' && Array.isArray(value))
|
|
67
|
+
return value.map(row => !object(row) ? row : ({ ...row,
|
|
68
|
+
hardwareAddress: typeof row.hardwareAddress === 'string' ? row.hardwareAddress.replace(/[:-]/g, '').toUpperCase().match(/../g)?.join('-') ?? '' : row.hardwareAddress,
|
|
69
|
+
hostName: row.hostName ?? '', comments: row.comments ?? '' })).sort((a, b) => String(a?.address).localeCompare(String(b?.address), 'en', { numeric: true }));
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
export function validateDhcp(key, value) {
|
|
73
|
+
validateConfiguration(dhcpFields[key], value);
|
|
74
|
+
const maximum = { leaseTimeDays: 65535, leaseTimeHours: 23, leaseTimeMinutes: 59,
|
|
75
|
+
offerDelayTime: 65535, pingCheckTimeout: 65535, pingCheckRetries: 255, dnsTtl: 4294967295 };
|
|
76
|
+
if (maximum[key] !== undefined && Number(value) > maximum[key])
|
|
77
|
+
invalid();
|
|
78
|
+
if (['name', 'newName'].includes(key) && value === '')
|
|
79
|
+
invalid();
|
|
80
|
+
if (['startingAddress', 'endingAddress', 'subnetMask', 'serverAddress', 'routerAddress'].includes(key) && value !== '' && isIP(String(value)) !== 4)
|
|
81
|
+
invalid();
|
|
82
|
+
if (['dnsServers', 'winsServers', 'ntpServers', 'capwapAcIpAddresses', 'tftpServerAddresses'].includes(key) && value.some(address => isIP(address) !== 4))
|
|
83
|
+
invalid();
|
|
84
|
+
if (['genericOptions', 'vendorInfo'].includes(key))
|
|
85
|
+
for (const row of value) {
|
|
86
|
+
const hex = row[key === 'genericOptions' ? 'value' : 'information'];
|
|
87
|
+
if (typeof hex !== 'string' || !/^(?:[\da-fA-F]{2})+$/.test(hex.replace(/:/g, '')) || key === 'genericOptions' && Number(row.code) > 255)
|
|
88
|
+
invalid();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
92
|
+
function invalid() { throw new ToolError('USAGE', 'Invalid configuration field value', 'Use the configuration field types and row columns shown in inventory'); }
|
|
93
|
+
export function validateConfiguration(field, value, resolved = false) {
|
|
94
|
+
if (field.secret && !resolved) {
|
|
95
|
+
if (!object(value) || Object.keys(value).length !== 1 || !(typeof value.env === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value.env) || typeof value.file === 'string' && isAbsolute(value.file)))
|
|
96
|
+
invalid();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (field.type === 'boolean') {
|
|
100
|
+
if (typeof value !== 'boolean')
|
|
101
|
+
invalid();
|
|
102
|
+
}
|
|
103
|
+
else if (field.type === 'integer') {
|
|
104
|
+
if (!Number.isSafeInteger(value) || Number(value) < 0)
|
|
105
|
+
invalid();
|
|
106
|
+
}
|
|
107
|
+
else if (field.type === 'string') {
|
|
108
|
+
if (typeof value !== 'string' || /[\x00\r]/.test(value) || value === '[REDACTED]')
|
|
109
|
+
invalid();
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
if (!Array.isArray(value))
|
|
113
|
+
invalid();
|
|
114
|
+
for (const item of value) {
|
|
115
|
+
if (field.type === 'ports') {
|
|
116
|
+
if (!Number.isInteger(item) || item < 0 || item > 65535)
|
|
117
|
+
invalid();
|
|
118
|
+
}
|
|
119
|
+
else if (field.type === 'list') {
|
|
120
|
+
if (typeof item !== 'string' || /[\x00-\x1f\x7f,]/.test(item) || item === '[REDACTED]')
|
|
121
|
+
invalid();
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
if (!object(item) || Object.keys(item).length !== field.columns.length)
|
|
125
|
+
invalid();
|
|
126
|
+
for (const column of field.columns) {
|
|
127
|
+
if (column === 'sharedSecret' && !resolved) {
|
|
128
|
+
validateConfiguration({ type: 'string', secret: true }, item[column]);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (['prefix', 'udpLimit', 'tcpLimit', 'code'].includes(column)) {
|
|
132
|
+
if (!Number.isSafeInteger(item[column]) || Number(item[column]) < 0)
|
|
133
|
+
invalid();
|
|
134
|
+
}
|
|
135
|
+
else if (typeof item[column] !== 'string' || /[|\x00-\x1f\x7f]/.test(item[column]) || item[column] === '[REDACTED]')
|
|
136
|
+
invalid();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function configurationForm(field, value) {
|
|
143
|
+
if (field?.type === 'rows')
|
|
144
|
+
return value.flatMap(row => field.columns.map(column => String(row[column]))).join('|');
|
|
145
|
+
return Array.isArray(value) ? value.join(',') : String(value);
|
|
146
|
+
}
|
|
147
|
+
export function settingValue(response, key) {
|
|
148
|
+
if (key.startsWith('proxy') && response.proxy === null)
|
|
149
|
+
return key === 'proxyType' ? 'None' : key === 'proxyPort' ? 0 : key === 'proxyBypass' ? [] : '';
|
|
150
|
+
if (key === 'enableLogging' && response.enableLogging === undefined)
|
|
151
|
+
return response.loggingType !== 'None';
|
|
152
|
+
const path = (settingsFields[key]?.read ?? key).split('.');
|
|
153
|
+
let value = response;
|
|
154
|
+
for (const part of path)
|
|
155
|
+
value = object(value) ? value[part] : undefined;
|
|
156
|
+
if (['proxyUsername', 'proxyPassword'].includes(key) && value == null)
|
|
157
|
+
return '';
|
|
158
|
+
if (key === 'proxyBypass' && value == null)
|
|
159
|
+
return [];
|
|
160
|
+
if (value === null && ['list', 'ports', 'rows'].includes(settingsFields[key]?.type ?? ''))
|
|
161
|
+
return [];
|
|
162
|
+
return value;
|
|
163
|
+
}
|