mcp-pacemaker 1.3.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +424 -0
  2. package/CONTRIBUTING.md +156 -0
  3. package/LICENSE +21 -0
  4. package/README.md +620 -0
  5. package/SECURITY.md +110 -0
  6. package/THIRD_PARTY_NOTICES.txt +366 -0
  7. package/autostart/windows/launcher.vbs +11 -0
  8. package/autostart/windows/register-task.ps1 +63 -0
  9. package/bin/cli.mjs +880 -0
  10. package/bin/config-checks.mjs +62 -0
  11. package/bin/logs.mjs +465 -0
  12. package/bin/mcp-bridge.mjs +2113 -0
  13. package/bin/pooling-config.mjs +553 -0
  14. package/bin/pooling-execution.mjs +91 -0
  15. package/bin/pooling-writer.mjs +169 -0
  16. package/bin/prewarm.mjs +152 -0
  17. package/bin/server-metrics.mjs +69 -0
  18. package/bin/service-control.mjs +124 -0
  19. package/bin/shared-sessions.mjs +1083 -0
  20. package/bin/top.mjs +115 -0
  21. package/bin/update-check.mjs +153 -0
  22. package/bin/windows/PoolingSecurityHelper.build.json +39 -0
  23. package/bin/windows/PoolingSecurityHelper.exe +0 -0
  24. package/bin/windows/src/AssemblyInfo.cs +11 -0
  25. package/bin/windows/src/PoolingSecurityHelper.cs +165 -0
  26. package/bin/windows/src/PoolingSecurityReader.cs +50 -0
  27. package/docs/shared-sessions.md +181 -0
  28. package/examples/servers.example.json +31 -0
  29. package/package.json +77 -0
  30. package/supervisor/bridge-child.mjs +14 -0
  31. package/supervisor/supervise.mjs +172 -0
  32. package/supervisor/supervise.ps1 +23 -0
  33. package/supervisor/supervise.sh +11 -0
  34. package/tools/windows-security-helper/README.md +95 -0
  35. package/tools/windows-security-helper/build.ps1 +262 -0
  36. package/ui/dist/THIRD_PARTY_NOTICES.txt +366 -0
  37. package/ui/dist/assets/index-CIy7uxfd.css +1 -0
  38. package/ui/dist/assets/index-CtaaQsB9.js +8 -0
  39. package/ui/dist/index.html +14 -0
  40. package/ui/dist/third-party-manifest.json +579 -0
package/bin/top.mjs ADDED
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mcp-pacemaker top — live terminal dashboard (Ink). Same data as the web /ui.
4
+ * Reads the bridge port from ~/.mcp-pacemaker/state.json (or --port) and the admin
5
+ * nonce from ~/.mcp-pacemaker/admin.nonce for the Recycle keybind. No JSX (uses createElement).
6
+ */
7
+ import { createElement as h, useState, useEffect } from 'react';
8
+ import { render, Box, Text, useApp, useInput } from 'ink';
9
+ import http from 'node:http';
10
+ import { readFileSync } from 'node:fs';
11
+ import { resolve } from 'node:path';
12
+ import { homedir } from 'node:os';
13
+
14
+ const HOME = resolve(homedir(), '.mcp-pacemaker');
15
+
16
+ export function fetchSnapshot(port) {
17
+ return new Promise((res) => {
18
+ const r = http.get({ host: '127.0.0.1', port, path: '/api/status', timeout: 2000 }, (resp) => {
19
+ let d = ''; resp.on('data', (c) => (d += c)); resp.on('end', () => { try { res(JSON.parse(d)); } catch { res(null); } });
20
+ });
21
+ r.on('error', () => res(null));
22
+ r.on('timeout', () => { r.destroy(); res(null); });
23
+ });
24
+ }
25
+
26
+ export function recycleServer(port, name, nonce) {
27
+ return new Promise((res) => {
28
+ const r = http.request({ host: '127.0.0.1', port, path: `/admin/recycle/${encodeURIComponent(name)}`, method: 'POST', headers: { 'x-mcp-nonce': nonce }, timeout: 3000 }, (resp) => {
29
+ let d = ''; resp.on('data', (c) => (d += c)); resp.on('end', () => { let body = null; try { body = JSON.parse(d || 'null'); } catch { /* noop */ } res({ status: resp.statusCode, body }); });
30
+ });
31
+ r.on('error', () => res(null));
32
+ r.on('timeout', () => { r.destroy(); res(null); });
33
+ r.end();
34
+ });
35
+ }
36
+
37
+ const readNonce = () => { try { return readFileSync(resolve(HOME, 'admin.nonce'), 'utf8').trim(); } catch { return ''; } };
38
+ // Pure: pick the bridge port from a parsed state object (list shape wins, legacy {port} fallback).
39
+ export function portFromState(state) {
40
+ if (state && Array.isArray(state.hosts)) return state.hosts[0]?.port || 8791;
41
+ return (state && state.port) || 8791;
42
+ }
43
+ function resolvePort(port) {
44
+ if (port) return port;
45
+ try { return portFromState(JSON.parse(readFileSync(resolve(HOME, 'state.json'), 'utf8'))); } catch { return 8791; }
46
+ }
47
+
48
+ const pad = (s, n) => { s = String(s ?? ''); return s.length >= n ? s.slice(0, n - 1) + ' ' : s.padEnd(n); };
49
+ const fmtTok = (sec) => (sec == null ? '-' : sec <= 0 ? 'exp' : `${Math.round(sec / 60)}m`);
50
+ // 'unknown' has to be visibly distinct from 'ok': the point of the health column is that a
51
+ // server nobody has called is not claimed to be working.
52
+ const HEALTH = { ok: { text: 'ok', color: 'green' }, failing: { text: 'FAIL', color: 'red' }, unknown: { text: '?', color: 'gray' } };
53
+ const healthCell = (s) => {
54
+ const st = s.health?.state ?? 'unknown';
55
+ const h = HEALTH[st] ?? HEALTH.unknown;
56
+ return { text: st === 'failing' && s.health.consecutiveFailures > 1 ? `FAIL×${s.health.consecutiveFailures}` : h.text, color: h.color };
57
+ };
58
+
59
+ function App({ port }) {
60
+ const { exit } = useApp();
61
+ const [snap, setSnap] = useState(null);
62
+ const [sel, setSel] = useState(0);
63
+ const [msg, setMsg] = useState('');
64
+
65
+ useEffect(() => {
66
+ let alive = true;
67
+ const tick = async () => { const s = await fetchSnapshot(port); if (alive) setSnap(s); };
68
+ tick();
69
+ const t = setInterval(tick, 1500);
70
+ return () => { alive = false; clearInterval(t); };
71
+ }, [port]);
72
+
73
+ const servers = snap?.servers ?? [];
74
+
75
+ useInput((input, key) => {
76
+ if (input === 'q' || (key.ctrl && input === 'c')) return exit();
77
+ if (key.upArrow) setSel((i) => Math.max(0, i - 1));
78
+ else if (key.downArrow) setSel((i) => Math.min(Math.max(0, servers.length - 1), i + 1));
79
+ else if (input === 'r') {
80
+ const s = servers[sel];
81
+ if (!s) return;
82
+ if (s.type === 'http') { setMsg('http servers have no child to recycle'); return; }
83
+ setMsg(`recycling ${s.name}…`);
84
+ recycleServer(port, s.name, readNonce()).then((r) =>
85
+ setMsg(r && r.status === 200 ? `recycled ${s.name} (${r.body?.recycled ?? 0})` : `recycle failed (${r?.status ?? 'no nonce/bridge'})`),
86
+ );
87
+ }
88
+ });
89
+
90
+ return h(
91
+ Box,
92
+ { flexDirection: 'column', padding: 1 },
93
+ h(
94
+ Box,
95
+ { justifyContent: 'space-between' },
96
+ h(Text, { bold: true, color: 'cyan' }, '🫀 mcp-pacemaker top'),
97
+ h(Text, { color: snap ? 'green' : 'red' }, snap ? `● :${snap.port} · up ${snap.uptimeSec}s · v${snap.version}` : '● connecting…'),
98
+ ),
99
+ h(Text, { dimColor: true }, pad('SERVER', 18) + pad('TYPE', 7) + pad('HEALTH', 8) + pad('SESS', 6) + pad('WARM', 6) + pad('REQ', 7) + pad('PID', 10) + pad('TOKEN', 8) + 'ERROR / CLIENTS'),
100
+ servers.length === 0
101
+ ? h(Text, { dimColor: true }, snap ? 'no servers configured' : 'connecting…')
102
+ : servers.map((s, i) =>
103
+ h(
104
+ Text,
105
+ { key: s.name, inverse: i === sel, color: s.health?.state === 'failing' ? 'red' : undefined },
106
+ pad(s.name, 18) + pad(s.type, 7) + pad(healthCell(s).text, 8) + pad(s.sessions, 6) + pad(s.sharing === 'pool' ? `${s.warm}/${s.minWarm ?? 1}` : '-', 6) + pad(s.requests, 7) + pad(s.pids.join(',') || '-', 10) + pad(fmtTok(s.tokenExpiresIn), 8) + (s.lastError ? String(s.lastError).slice(0, 24) : (s.clients && s.clients.length ? '\u21c4 ' + s.clients.join(',') : '')),
107
+ ),
108
+ ),
109
+ h(Text, { dimColor: true }, `↑↓ select · r recycle · q quit ${msg}`),
110
+ );
111
+ }
112
+
113
+ export function runTop(port) {
114
+ render(h(App, { port: resolvePort(port) }));
115
+ }
@@ -0,0 +1,153 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs';
3
+ import { dirname, join, resolve, delimiter } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { promisify } from 'node:util';
6
+ import semver from 'semver';
7
+
8
+ const runFile = promisify(execFile);
9
+
10
+ export function updateChannel(pkg) {
11
+ if (typeof pkg.name !== 'string' ||
12
+ !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(pkg.name)) {
13
+ throw new Error('Invalid installed package name.');
14
+ }
15
+ if (typeof pkg.version !== 'string' ||
16
+ !semver.valid(pkg.version) ||
17
+ semver.prerelease(pkg.version)) {
18
+ throw new Error('The installed CLI must have a stable SemVer version.');
19
+ }
20
+ const major = semver.major(pkg.version);
21
+ if (major !== 1 &&
22
+ major !== 2) {
23
+ throw new Error(`No update channel is defined for major ${major}.`);
24
+ }
25
+ return major === 1 ? 'legacy' : 'latest';
26
+ }
27
+
28
+ export function channelTarget(pkg, tags) {
29
+ const channel = updateChannel(pkg);
30
+ const target = tags?.[channel];
31
+ if (typeof target !== 'string' ||
32
+ semver.valid(target) !== target ||
33
+ semver.prerelease(target) ||
34
+ semver.major(target) !== semver.major(pkg.version)) {
35
+ throw new Error(`Channel "${channel}" has no valid stable same-major target. No fallback selected.`);
36
+ }
37
+ return target;
38
+ }
39
+
40
+ export function selectUpdate(pkg, tags, release) {
41
+ const channel = updateChannel(pkg);
42
+ const target = channelTarget(pkg, tags);
43
+ if (!release ||
44
+ Array.isArray(release) ||
45
+ release.name !== pkg.name ||
46
+ release.version !== target) {
47
+ throw new Error(`The exact "${channel}" target could not be verified.`);
48
+ }
49
+ if (release.deprecated !== undefined &&
50
+ release.deprecated !== '') {
51
+ throw new Error(`The "${channel}" target is deprecated. No fallback selected.`);
52
+ }
53
+ const order = semver.compare(target, pkg.version);
54
+ return {
55
+ name: pkg.name,
56
+ current: pkg.version,
57
+ channel,
58
+ target,
59
+ status: order > 0 ? 'update-available' : order < 0 ? 'installed-ahead' : 'current',
60
+ updateAvailable: order > 0,
61
+ rollbackRequired: order < 0,
62
+ };
63
+ }
64
+
65
+ export function validateRegistry(registry) {
66
+ if (registry === undefined) return undefined;
67
+ const url = new URL(registry);
68
+ if (!['https:', 'http:'].includes(url.protocol) ||
69
+ url.username ||
70
+ url.password ||
71
+ url.search ||
72
+ url.hash) {
73
+ throw new Error('Registry must be an HTTP(S) URL without credentials, query, or fragment. Use npm configuration for authentication.');
74
+ }
75
+ return url.href;
76
+ }
77
+
78
+ function npmCliPath() {
79
+ const candidates = [];
80
+ if (process.env.npm_execpath) candidates.push(process.env.npm_execpath);
81
+ candidates.push(join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'));
82
+ for (const directory of (process.env.PATH || '').split(delimiter)) {
83
+ if (!directory) continue;
84
+ candidates.push(join(directory, 'node_modules', 'npm', 'bin', 'npm-cli.js'));
85
+ const launcher = join(directory, 'npm');
86
+ if (existsSync(launcher)) candidates.push(realpathSync(launcher));
87
+ }
88
+ const path = candidates.find((candidate) => candidate.endsWith('npm-cli.js') && existsSync(candidate));
89
+ if (!path) throw new Error('npm CLI not found. Install npm with Node.js or run through npm; no registry fallback was attempted.');
90
+ return resolve(path);
91
+ }
92
+
93
+ export async function readNpmMetadata(spec, field, { registry, cache, run = runFile } = {}) {
94
+ const args = [npmCliPath(), 'view', spec];
95
+ if (field) args.push(field);
96
+ args.push('--json', '--prefer-online', '--ignore-scripts', '--fetch-retries=0', '--fetch-timeout=8000', '--cache', cache);
97
+ if (registry !== undefined) args.push('--registry', validateRegistry(registry));
98
+ let stdout;
99
+ try {
100
+ ({ stdout } = await run(process.execPath, args, {
101
+ encoding: 'utf8', timeout: 12000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
102
+ }));
103
+ } catch {
104
+ // npm's diagnostics can contain private registry URLs and authentication details.
105
+ throw new Error('Registry metadata unavailable (offline, denied, missing, or npm failed). Check your approved npm configuration; no fallback selected.');
106
+ }
107
+ try {
108
+ return JSON.parse(stdout);
109
+ } catch {
110
+ throw new Error('npm returned invalid metadata. No fallback selected.');
111
+ }
112
+ }
113
+
114
+ export async function checkForUpdate(pkg, { registry, read = readNpmMetadata } = {}) {
115
+ updateChannel(pkg);
116
+ registry = validateRegistry(registry);
117
+ // An empty cache prevents an offline/stale response from certifying channel state.
118
+ const cache = mkdtempSync(join(tmpdir(), 'mcp-update-'));
119
+ try {
120
+ const options = { registry, cache };
121
+ const tags = await read(pkg.name, 'dist-tags', options);
122
+ const target = channelTarget(pkg, tags);
123
+ const release = await read(`${pkg.name}@${target}`, undefined, options);
124
+ return selectUpdate(pkg, tags, release);
125
+ } finally {
126
+ rmSync(cache, { recursive: true, force: true });
127
+ }
128
+ }
129
+
130
+ export function updateGuidance(result, { registry, platform = process.platform } = {}) {
131
+ const quote = (value) => platform === 'win32'
132
+ ? `'${value.replaceAll("'", "''")}'`
133
+ : `'${value.replaceAll("'", "'\\''")}'`;
134
+ const registryOption = registry === undefined ? '' : ` --registry ${quote(validateRegistry(registry))}`;
135
+ const action = result.rollbackRequired
136
+ ? 'Installed CLI is ahead of its channel. This is a deliberate rollback, NOT a normal update.'
137
+ : result.updateAvailable ? 'A same-major CLI update is available.' : 'Installed CLI matches its channel.';
138
+ return [
139
+ `${result.name}: installed CLI ${result.current}; ${result.channel} -> ${result.target}. ${action}`,
140
+ 'Guidance only: no package, service, autostart, or host configuration was changed.',
141
+ 'Before replacement: identify every service using the installation root, its supervisor, autostart, port and config directory.',
142
+ 'For a 2.x downgrade, settle/cancel batches and recover interrupted transactions with 2.x first. Keep protected backups; never delete transaction files.',
143
+ 'Use the matching installation CLI to stop the selected managed service: mcp-pacemaker stop --port <port>.',
144
+ 'A successful managed stop holds its supervisor/autostart until an explicit start. Older/unmanaged supervisors require separately verified OS-service shutdown and disabled autostart.',
145
+ 'Do not overwrite ANY root still used by a bridge or supervisor: workers, helpers and UI load files later.',
146
+ 'Only after shutdown is verified, and after a deliberate rollback decision if installed-ahead:',
147
+ ` npm install --global ${result.name}@${result.target}${registryOption}`,
148
+ 'Use the same approved npm registry configuration. Prefer a separate durable version root; one global prefix replaces the shared CLI bins.',
149
+ 'Re-register the intended durable autostart path if it changed, start deliberately, and verify backend version and a new instance ID. Refresh dashboard tabs and check active config, permissions and tool calls.',
150
+ 'Moving to another major is a separate migration; no cross-major target was selected.',
151
+ 'Side-by-side services need separate durable roots, config directories, HOME/host state and ports. import/init --config is an input host config, not a runtime profile.',
152
+ ].join('\n');
153
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "binary": "PoolingSecurityHelper.exe",
4
+ "binarySha256": "effdc8deb06beab195523f714b8d1a396ec53f00b6b4a437e251a25d94234c9e",
5
+ "inputs": {
6
+ "targetFramework": ".NETFramework,Version=v4.6.2",
7
+ "platform": "AnyCPU",
8
+ "compilerVersion": "5.9.0-1.26357.3 (35d9211b841e7613c1d2f8f5af6d628ace696c4c)",
9
+ "compilerSha256": "3aafb7b9c54fa31a7092af35148971ee616965e7f9a0b80fb7fdc4bdd1d1a555",
10
+ "compilerArguments": [
11
+ "/nologo",
12
+ "/noconfig",
13
+ "/nostdlib+",
14
+ "/target:exe",
15
+ "/platform:anycpu",
16
+ "/langversion:7.3",
17
+ "/optimize+",
18
+ "/debug-",
19
+ "/deterministic+",
20
+ "/checked-",
21
+ "/unsafe-",
22
+ "/warn:4",
23
+ "/warnaserror+",
24
+ "/utf8output"
25
+ ],
26
+ "pathMap": "/_/mcp-pacemaker/windows-security-helper",
27
+ "sourceEncoding": "UTF-8 without BOM, LF line endings",
28
+ "sourceSha256": {
29
+ "AssemblyInfo.cs": "e81e107352fbcb82818d8265dfcdf392eac8d25df37e6d65ca7f02d1e63a6c9a",
30
+ "PoolingSecurityReader.cs": "91620ba5e0b3c6fcc673319f59c96466115a6be74fd8b93808f08f07087b7038",
31
+ "PoolingSecurityHelper.cs": "aff0222cf28258f1004908b0ad6e471d00012d5ea7dd90af163d83eb743df6f0"
32
+ },
33
+ "buildScriptSha256": "29dcb4641182bfe5552e9e913f90d4a37e99ddc7677b65b7dcb22cae16d98c60",
34
+ "referenceSha256": {
35
+ "mscorlib.dll": "7118fe658567606fa0538f7b5a2071e4606756c07e9062e1e5b24fa49e97d1f6",
36
+ "System.dll": "b76822f9c08aff597120e3167b3e0fbda836d6bc6f14bc4bc2eeffab5b2c6125"
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,11 @@
1
+ using System.Reflection;
2
+ using System.Runtime.InteropServices;
3
+ using System.Runtime.Versioning;
4
+
5
+ [assembly: AssemblyTitle("mcp-pacemaker Windows security helper")]
6
+ [assembly: AssemblyDescription("Inspects and preserves configuration file security.")]
7
+ [assembly: AssemblyProduct("mcp-pacemaker")]
8
+ [assembly: AssemblyVersion("1.0.0.0")]
9
+ [assembly: AssemblyFileVersion("1.0.0.0")]
10
+ [assembly: ComVisible(false)]
11
+ [assembly: TargetFramework(".NETFramework,Version=v4.6.2")]
@@ -0,0 +1,165 @@
1
+ using System;
2
+ using System.ComponentModel;
3
+ using System.IO;
4
+ using System.Security.AccessControl;
5
+ using System.Security.Cryptography;
6
+
7
+ public sealed class PoolingSecurityHelper
8
+ {
9
+ private readonly PoolingSecurityReader nativeReader = new PoolingSecurityReader();
10
+ private readonly AccessControlSections sections =
11
+ AccessControlSections.Access | AccessControlSections.Owner | AccessControlSections.Group;
12
+
13
+ /// <summary>Inspects or preserves Windows file security.</summary>
14
+ /// <param name="args">One action: inspect or copy. File paths come from MCP_POOL environment variables.</param>
15
+ /// <returns>Zero on success, three on a source security conflict, or one on failure.</returns>
16
+ public static int Main(string[] args)
17
+ {
18
+ return new PoolingSecurityHelper().Run(args);
19
+ }
20
+
21
+ /// <summary>Runs a security operation without changing file contents.</summary>
22
+ /// <param name="args">One action: inspect or copy.</param>
23
+ /// <returns>Zero on success, three on a source security conflict, or one on failure.</returns>
24
+ public int Run(string[] args)
25
+ {
26
+ try
27
+ {
28
+ if (args.Length != 1)
29
+ {
30
+ throw new ArgumentException();
31
+ }
32
+
33
+ var sourcePath = this.RequiredEnvironment("MCP_POOL_SOURCE");
34
+ if (args[0] == "inspect")
35
+ {
36
+ Console.WriteLine(this.ReadSecurity(sourcePath).Fingerprint);
37
+ return 0;
38
+ }
39
+
40
+ if (args[0] == "copy")
41
+ {
42
+ return this.CopySecurity(sourcePath);
43
+ }
44
+
45
+ throw new ArgumentException();
46
+ }
47
+ catch (Exception error)
48
+ {
49
+ Console.Error.WriteLine("MCPERR type=" + error.GetType().Name);
50
+ var nativeError = error as Win32Exception;
51
+ if (nativeError != null)
52
+ {
53
+ Console.Error.WriteLine("MCPERR nativeError=" + nativeError.NativeErrorCode);
54
+ }
55
+
56
+ return 1;
57
+ }
58
+ }
59
+
60
+ private string RequiredEnvironment(string name)
61
+ {
62
+ var value = Environment.GetEnvironmentVariable(name);
63
+ if (string.IsNullOrEmpty(value))
64
+ {
65
+ throw new ArgumentException();
66
+ }
67
+
68
+ return value;
69
+ }
70
+
71
+ private SecurityState ReadSecurity(string path)
72
+ {
73
+ var acl = File.GetAccessControl(path);
74
+ var descriptor = acl.GetSecurityDescriptorSddlForm(this.sections);
75
+ var native = this.nativeReader.ReadControlDescriptor(path);
76
+ bool complete = true;
77
+ var audit = new byte[0];
78
+ try
79
+ {
80
+ audit = this.ReadAudit(path);
81
+ }
82
+ catch (PrivilegeNotHeldException)
83
+ {
84
+ complete = false;
85
+ }
86
+ catch (UnauthorizedAccessException)
87
+ {
88
+ complete = false;
89
+ }
90
+
91
+ int attributes = (int)File.GetAttributes(path);
92
+ using (var hash = SHA256.Create())
93
+ using (var buffer = new MemoryStream())
94
+ using (var writer = new BinaryWriter(buffer))
95
+ {
96
+ writer.Write(native.Length);
97
+ writer.Write(native);
98
+ writer.Write(audit.Length);
99
+ writer.Write(audit);
100
+ writer.Write(attributes);
101
+ writer.Flush();
102
+ var scope = complete ? "F:" : "P:";
103
+ return new SecurityState
104
+ {
105
+ Descriptor = descriptor,
106
+ Fingerprint = scope + Convert.ToBase64String(hash.ComputeHash(buffer.ToArray())),
107
+ };
108
+ }
109
+ }
110
+
111
+ private byte[] ReadAudit(string path)
112
+ {
113
+ return File.GetAccessControl(path, AccessControlSections.Audit).GetSecurityDescriptorBinaryForm();
114
+ }
115
+
116
+ private int CopySecurity(string sourcePath)
117
+ {
118
+ var expectedFingerprint = this.RequiredEnvironment("MCP_POOL_SECURITY");
119
+ var source = this.ReadSecurity(sourcePath);
120
+ if (!string.Equals(source.Fingerprint, expectedFingerprint, StringComparison.Ordinal))
121
+ {
122
+ return 3;
123
+ }
124
+
125
+ var paths = new[]
126
+ {
127
+ this.RequiredEnvironment("MCP_POOL_TEMP"),
128
+ this.RequiredEnvironment("MCP_POOL_BACKUP"),
129
+ };
130
+ foreach (var path in paths)
131
+ {
132
+ var actual = File.GetAccessControl(path);
133
+ if (!string.Equals(actual.GetSecurityDescriptorSddlForm(this.sections),
134
+ source.Descriptor, StringComparison.Ordinal))
135
+ {
136
+ var copy = new FileSecurity();
137
+ copy.SetSecurityDescriptorSddlForm(source.Descriptor, this.sections);
138
+ File.SetAccessControl(path, copy);
139
+ actual = File.GetAccessControl(path);
140
+ }
141
+
142
+ if (!string.Equals(actual.GetSecurityDescriptorSddlForm(this.sections),
143
+ source.Descriptor, StringComparison.Ordinal))
144
+ {
145
+ throw new InvalidOperationException();
146
+ }
147
+
148
+ if (!string.Equals(this.ReadSecurity(path).Fingerprint,
149
+ expectedFingerprint, StringComparison.Ordinal))
150
+ {
151
+ throw new InvalidOperationException();
152
+ }
153
+ }
154
+
155
+ return string.Equals(this.ReadSecurity(sourcePath).Fingerprint,
156
+ expectedFingerprint, StringComparison.Ordinal) ? 0 : 3;
157
+ }
158
+
159
+ private sealed class SecurityState
160
+ {
161
+ public string Descriptor { get; set; }
162
+
163
+ public string Fingerprint { get; set; }
164
+ }
165
+ }
@@ -0,0 +1,50 @@
1
+ using System;
2
+ using System.ComponentModel;
3
+ using System.Runtime.InteropServices;
4
+ using System.Security.AccessControl;
5
+
6
+ public sealed class PoolingSecurityReader
7
+ {
8
+ private const uint ReadControlSections = 0x1f7;
9
+
10
+ /// <summary>Reads native owner, DACL and non-audit security sections.</summary>
11
+ /// <param name="path">File whose descriptor is queried.</param>
12
+ /// <returns>The unfiltered sections in canonical binary form.</returns>
13
+ public byte[] ReadControlDescriptor(string path)
14
+ {
15
+ var descriptor = IntPtr.Zero;
16
+ try
17
+ {
18
+ uint status = GetNamedSecurityInfo(path, 1, ReadControlSections,
19
+ IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, out descriptor);
20
+ if (status != 0)
21
+ {
22
+ throw new Win32Exception((int)status);
23
+ }
24
+
25
+ var native = new byte[GetSecurityDescriptorLength(descriptor)];
26
+ Marshal.Copy(descriptor, native, 0, native.Length);
27
+ var raw = new RawSecurityDescriptor(native, 0);
28
+ var canonical = new byte[raw.BinaryLength];
29
+ raw.GetBinaryForm(canonical, 0);
30
+ return canonical;
31
+ }
32
+ finally
33
+ {
34
+ if (descriptor != IntPtr.Zero)
35
+ {
36
+ LocalFree(descriptor);
37
+ }
38
+ }
39
+ }
40
+
41
+ [DllImport("advapi32.dll", EntryPoint = "GetNamedSecurityInfoW", CharSet = CharSet.Unicode)]
42
+ private static extern uint GetNamedSecurityInfo(string path, uint objectType, uint sections,
43
+ IntPtr owner, IntPtr group, IntPtr dacl, IntPtr sacl, out IntPtr descriptor);
44
+
45
+ [DllImport("advapi32.dll")]
46
+ private static extern uint GetSecurityDescriptorLength(IntPtr descriptor);
47
+
48
+ [DllImport("kernel32.dll")]
49
+ private static extern IntPtr LocalFree(IntPtr memory);
50
+ }