480 lines
18 KiB
PHP
480 lines
18 KiB
PHP
<?php
|
|
/**
|
|
* Unraid Docker MCP Helper
|
|
* Write-Operationen für das Unraid MCP
|
|
* Aufruf: php /usr/local/bin/unraid-docker-mcp-helper.php <action> <args...>
|
|
*
|
|
* Actions:
|
|
* create <template_name>
|
|
* modify <container> <field> <value>
|
|
* update <container>
|
|
* update-verified-batch <container1|container2|...>
|
|
* rebuild <container>
|
|
* ca-install <template_url> <container_name> <overrides_json> <start:true|false>
|
|
*
|
|
* modify fields:
|
|
* port value: newExternalPort (z.B. "8466")
|
|
* oder newExternalPort:internalPort (z.B. "8466:8465")
|
|
* env value: VAR_NAME=VAR_VALUE (z.B. "TZ=Europe/Berlin")
|
|
* volume value: hostPath:containerPath (z.B. "/mnt/user/data:/data")
|
|
* network value: bridge|host|none
|
|
* privileged value: true|false
|
|
*/
|
|
|
|
error_reporting(E_ALL);
|
|
ini_set('display_errors', 1);
|
|
|
|
// ── Unraid Setup (aus update_container) ──────────────────────────────────
|
|
$docroot = '/usr/local/emhttp';
|
|
require_once "$docroot/webGui/include/Wrappers.php";
|
|
extract(parse_plugin_cfg('dynamix', true));
|
|
|
|
$_SERVER['REQUEST_URI'] = '';
|
|
$login_locale = _var($display, 'locale');
|
|
require_once "$docroot/plugins/dynamix.docker.manager/include/DockerClient.php";
|
|
|
|
$var = parse_ini_file('/var/local/emhttp/var.ini');
|
|
$DockerClient = new DockerClient();
|
|
$DockerUpdate = new DockerUpdate();
|
|
$DockerTemplates = new DockerTemplates();
|
|
|
|
$custom = DockerUtil::custom();
|
|
$subnet = DockerUtil::network($custom);
|
|
$cpus = DockerUtil::cpus();
|
|
|
|
// ── Helper-Funktionen ────────────────────────────────────────────────────
|
|
function out(string $msg): void {
|
|
echo $msg . "\n";
|
|
}
|
|
|
|
function fail(string $msg): never {
|
|
out("ERROR: " . $msg);
|
|
exit(1);
|
|
}
|
|
|
|
function docker_exec(string $cmd): string {
|
|
$proc = popen("/usr/bin/docker $cmd 2>&1", 'r');
|
|
$output = stream_get_contents($proc);
|
|
pclose($proc);
|
|
return trim($output);
|
|
}
|
|
|
|
function get_template_path(string $name): string {
|
|
$dir = '/boot/config/plugins/dockerMan/templates-user';
|
|
$file = "$dir/my-$name.xml";
|
|
if (!file_exists($file)) {
|
|
fail("Template not found: $file");
|
|
}
|
|
return $file;
|
|
}
|
|
|
|
function rebuild_container(string $name, bool $pull_image = false, bool $force_start = false): void {
|
|
global $DockerClient;
|
|
$tmpl = get_template_path($name);
|
|
$xml = file_get_contents($tmpl);
|
|
[$cmd, $Name, $Repository] = xmlToCommand($tmpl);
|
|
|
|
// Pull image if requested
|
|
if ($pull_image) {
|
|
out("Pulling image: $Repository");
|
|
$pull_out = docker_exec("pull $Repository");
|
|
if (strpos($pull_out, 'Error') !== false || strpos($pull_out, 'error') !== false) {
|
|
fail("Image pull failed: $pull_out");
|
|
}
|
|
out("Image pulled: $Repository");
|
|
}
|
|
|
|
// Check if container is running
|
|
$oldContainerInfo = $DockerClient->getContainerDetails($Name);
|
|
$startContainer = $force_start;
|
|
if (!empty($oldContainerInfo) && !empty($oldContainerInfo['State']) && !empty($oldContainerInfo['State']['Running'])) {
|
|
$startContainer = true;
|
|
out("Stopping container: $Name");
|
|
$DockerClient->stopContainer($Name);
|
|
}
|
|
|
|
// Convert create to run if we need to start
|
|
if ($startContainer) {
|
|
$cmd = str_replace('/docker create ', '/docker run -d ', $cmd);
|
|
}
|
|
|
|
// Remove old container
|
|
out("Removing old container: $Name");
|
|
$DockerClient->removeContainer($Name);
|
|
|
|
// Execute the docker command
|
|
out("Creating container: $Name");
|
|
$proc = popen("$cmd 2>&1", 'r');
|
|
$output = stream_get_contents($proc);
|
|
$rc = pclose($proc);
|
|
if ($rc !== 0) {
|
|
fail("Container creation failed (exit $rc): $output");
|
|
}
|
|
out("Container created: $Name");
|
|
|
|
// Flush caches
|
|
$DockerClient->flushCaches();
|
|
out("Done: $Name");
|
|
}
|
|
|
|
function docker_inspect_value(string $format, string $name): string {
|
|
return docker_exec('inspect --format ' . escapeshellarg($format) . ' ' . escapeshellarg($name));
|
|
}
|
|
|
|
/**
|
|
* Pull and update several exact containers without trusting Unraid's cached
|
|
* update flag. The immutable image ID is the authority. A stale cache can
|
|
* therefore never cause a needless container recreation.
|
|
*/
|
|
function update_verified_batch(string $raw_names): void {
|
|
$names = array_values(array_unique(array_filter(array_map('trim', explode('|', $raw_names)))));
|
|
if (count($names) < 1 || count($names) > 25) {
|
|
fail('Expected 1-25 container names separated by |');
|
|
}
|
|
|
|
$results = [];
|
|
foreach ($names as $name) {
|
|
if (!preg_match('/^[a-zA-Z0-9._-]{1,128}$/', $name)) {
|
|
$results[] = ['container' => $name, 'result' => 'error', 'error' => 'invalid container name'];
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$tmpl = get_template_path($name);
|
|
[, $template_name, $repository] = xmlToCommand($tmpl);
|
|
if ($template_name !== $name) {
|
|
throw new RuntimeException("Template name mismatch: $template_name");
|
|
}
|
|
|
|
$before_id = docker_inspect_value('{{.Id}}', $name);
|
|
$before_image_id = docker_inspect_value('{{.Image}}', $name);
|
|
$before_state = docker_inspect_value('{{.State.Status}}', $name);
|
|
$was_running = docker_inspect_value('{{.State.Running}}', $name) === 'true';
|
|
|
|
$pull_output = docker_exec('pull ' . escapeshellarg($repository));
|
|
if (preg_match('/(?:^|\n)(?:Error response|error:|failed)/i', $pull_output)) {
|
|
throw new RuntimeException('Image pull failed: ' . substr($pull_output, 0, 500));
|
|
}
|
|
$pulled_image_id = docker_exec('image inspect --format ' . escapeshellarg('{{.Id}}') . ' ' . escapeshellarg($repository));
|
|
if ($pulled_image_id === '') {
|
|
throw new RuntimeException('Pulled image ID could not be resolved');
|
|
}
|
|
|
|
if ($before_image_id === $pulled_image_id) {
|
|
$results[] = [
|
|
'container' => $name,
|
|
'result' => 'already-current',
|
|
'recreated' => false,
|
|
'original_state' => $before_state,
|
|
'final_state' => $before_state,
|
|
'container_id_changed' => false,
|
|
'image_id_changed' => false,
|
|
];
|
|
continue;
|
|
}
|
|
|
|
ob_start();
|
|
rebuild_container($name, false, false);
|
|
ob_end_clean();
|
|
|
|
$after_id = docker_inspect_value('{{.Id}}', $name);
|
|
$after_image_id = docker_inspect_value('{{.Image}}', $name);
|
|
$after_state = docker_inspect_value('{{.State.Status}}', $name);
|
|
$restart_count = (int)docker_inspect_value('{{.RestartCount}}', $name);
|
|
$health = docker_inspect_value('{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}', $name);
|
|
$state_preserved = $was_running ? $after_state === 'running' : $after_state !== 'running';
|
|
|
|
$results[] = [
|
|
'container' => $name,
|
|
'result' => ($after_image_id === $pulled_image_id && $state_preserved) ? 'updated' : 'verification-failed',
|
|
'recreated' => true,
|
|
'original_state' => $before_state,
|
|
'final_state' => $after_state,
|
|
'state_preserved' => $state_preserved,
|
|
'container_id_changed' => $before_id !== $after_id,
|
|
'image_id_changed' => $before_image_id !== $after_image_id,
|
|
'restart_count' => $restart_count,
|
|
'health' => $health,
|
|
];
|
|
} catch (Throwable $error) {
|
|
if (ob_get_level() > 0) ob_end_clean();
|
|
$results[] = [
|
|
'container' => $name,
|
|
'result' => 'error',
|
|
'error' => substr($error->getMessage(), 0, 600),
|
|
];
|
|
}
|
|
}
|
|
|
|
$counts = [];
|
|
foreach ($results as $result) {
|
|
$key = (string)$result['result'];
|
|
$counts[$key] = ($counts[$key] ?? 0) + 1;
|
|
}
|
|
echo json_encode([
|
|
'schema_version' => '1.0',
|
|
'requested_count' => count($names),
|
|
'result_counts' => $counts,
|
|
'all_verified' => !isset($counts['error']) && !isset($counts['verification-failed']),
|
|
'containers' => $results,
|
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
|
|
}
|
|
|
|
/**
|
|
* Set the text content of a DOM element.
|
|
* This is the actual value Unraid uses (not the Default attribute).
|
|
*/
|
|
function set_config_value(DOMElement $config, string $value): void {
|
|
// Remove existing text children
|
|
while ($config->firstChild) {
|
|
$config->removeChild($config->firstChild);
|
|
}
|
|
$config->appendChild(new DOMText($value));
|
|
// Also update Default attribute for consistency
|
|
$config->setAttribute('Default', $value);
|
|
}
|
|
|
|
function modify_template(string $name, string $field, string $value): void {
|
|
$tmpl = get_template_path($name);
|
|
$xml = file_get_contents($tmpl);
|
|
$dom = new DOMDocument();
|
|
$dom->loadXML($xml);
|
|
|
|
switch ($field) {
|
|
case 'port':
|
|
// value: newExternalPort or newExternalPort:internalPort
|
|
$parts = explode(':', $value, 2);
|
|
$newExternal = $parts[0];
|
|
$newInternal = $parts[1] ?? null;
|
|
|
|
$found = false;
|
|
foreach ($dom->getElementsByTagName('Config') as $config) {
|
|
if (strcasecmp($config->getAttribute('Type'), 'Port') === 0) {
|
|
if ($newInternal !== null) {
|
|
// Match by internal port (Target attribute)
|
|
if ($config->getAttribute('Target') === $newInternal) {
|
|
$config->setAttribute('Target', $newInternal);
|
|
set_config_value($config, $newExternal);
|
|
$found = true;
|
|
break;
|
|
}
|
|
} else {
|
|
// No internal port specified — change first Port config
|
|
set_config_value($config, $newExternal);
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!$found) {
|
|
fail("Port config not found in template" . ($newInternal !== null ? " (Target=$newInternal)" : ""));
|
|
}
|
|
break;
|
|
|
|
case 'env':
|
|
// value: VAR_NAME=VAR_VALUE
|
|
$eqPos = strpos($value, '=');
|
|
if ($eqPos === false) {
|
|
fail("Env value must be VAR_NAME=VAR_VALUE, got: $value");
|
|
}
|
|
$varName = substr($value, 0, $eqPos);
|
|
$varValue = substr($value, $eqPos + 1);
|
|
|
|
$found = false;
|
|
foreach ($dom->getElementsByTagName('Config') as $config) {
|
|
if (strcasecmp($config->getAttribute('Type'), 'Variable') === 0) {
|
|
if ($config->getAttribute('Target') === $varName) {
|
|
set_config_value($config, $varValue);
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!$found) {
|
|
fail("Env var $varName not found in template");
|
|
}
|
|
break;
|
|
|
|
case 'volume':
|
|
// value: hostPath:containerPath
|
|
$colonPos = strpos($value, ':');
|
|
if ($colonPos === false) {
|
|
fail("Volume value must be hostPath:containerPath, got: $value");
|
|
}
|
|
$hostPath = substr($value, 0, $colonPos);
|
|
$containerPath = substr($value, $colonPos + 1);
|
|
|
|
$found = false;
|
|
foreach ($dom->getElementsByTagName('Config') as $config) {
|
|
if (strcasecmp($config->getAttribute('Type'), 'Path') === 0) {
|
|
if ($config->getAttribute('Target') === $containerPath) {
|
|
set_config_value($config, $hostPath);
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!$found) {
|
|
fail("Volume target $containerPath not found in template");
|
|
}
|
|
break;
|
|
|
|
case 'network':
|
|
$networks = $dom->getElementsByTagName('Network');
|
|
if ($networks->length === 0) {
|
|
fail("No Network element in template");
|
|
}
|
|
$networks->item(0)->nodeValue = $value;
|
|
break;
|
|
|
|
case 'privileged':
|
|
$privs = $dom->getElementsByTagName('Privileged');
|
|
if ($privs->length === 0) {
|
|
fail("No Privileged element in template");
|
|
}
|
|
$privs->item(0)->nodeValue = $value;
|
|
break;
|
|
|
|
default:
|
|
fail("Unknown field: $field (use: port|env|volume|network|privileged)");
|
|
}
|
|
|
|
// Write modified template
|
|
$dom->save($tmpl);
|
|
out("Template modified: $name ($field = $value)");
|
|
|
|
// Rebuild container (force start so it's running after modify)
|
|
rebuild_container($name, false, true);
|
|
}
|
|
|
|
function ca_install(string $template_url, string $name, string $overrides_json, bool $start): void {
|
|
if (!preg_match('/^[a-zA-Z0-9._-]{1,128}$/', $name)) {
|
|
fail('Invalid container name');
|
|
}
|
|
$url = parse_url($template_url);
|
|
if (!is_array($url) || ($url['scheme'] ?? '') !== 'https' || empty($url['host'])) {
|
|
fail('Community Applications template must use HTTPS');
|
|
}
|
|
$host = strtolower((string)$url['host']);
|
|
if ($host === 'localhost' || str_ends_with($host, '.local')) {
|
|
fail('Private template hosts are rejected');
|
|
}
|
|
if (filter_var($host, FILTER_VALIDATE_IP) &&
|
|
!filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
|
fail('Private or reserved template IPs are rejected');
|
|
}
|
|
|
|
$template_path = "/boot/config/plugins/dockerMan/templates-user/my-$name.xml";
|
|
if (file_exists($template_path)) fail("Template already exists: $name");
|
|
$existing = trim(docker_exec(
|
|
'ps -a --filter ' . escapeshellarg("name=^/$name$") . ' --format ' . escapeshellarg('{{.Names}}')
|
|
));
|
|
if (in_array($name, preg_split('/\R/', $existing) ?: [], true)) fail("Container already exists: $name");
|
|
|
|
$context = stream_context_create(['http' => [
|
|
'timeout' => 30,
|
|
'follow_location' => 0,
|
|
'user_agent' => 'MUA Community Applications Installer',
|
|
]]);
|
|
$xml = @file_get_contents($template_url, false, $context, 0, 1024 * 1024 + 1);
|
|
if ($xml === false || strlen($xml) === 0) fail('Unable to download Community Applications template');
|
|
if (strlen($xml) > 1024 * 1024) fail('Community Applications template is too large');
|
|
|
|
$dom = new DOMDocument();
|
|
$old = libxml_use_internal_errors(true);
|
|
$loaded = $dom->loadXML($xml, LIBXML_NONET | LIBXML_NOBLANKS);
|
|
libxml_clear_errors();
|
|
libxml_use_internal_errors($old);
|
|
if (!$loaded || !$dom->documentElement || $dom->documentElement->nodeName !== 'Container') {
|
|
fail('Invalid Community Applications container template');
|
|
}
|
|
$repositories = $dom->getElementsByTagName('Repository');
|
|
if ($repositories->length !== 1 || trim($repositories->item(0)->textContent) === '') {
|
|
fail('Template has no valid Docker repository');
|
|
}
|
|
$names = $dom->getElementsByTagName('Name');
|
|
if ($names->length === 0) {
|
|
$name_node = $dom->createElement('Name', $name);
|
|
$dom->documentElement->insertBefore($name_node, $dom->documentElement->firstChild);
|
|
} else {
|
|
$names->item(0)->nodeValue = $name;
|
|
}
|
|
|
|
$overrides = json_decode($overrides_json, true);
|
|
if (!is_array($overrides) || count($overrides) > 32) fail('Invalid overrides object');
|
|
foreach ($overrides as $target => $value) {
|
|
if ((!is_string($target) && !is_int($target)) || !is_string($value) || strlen($value) > 4096) fail('Invalid override');
|
|
$target = (string)$target;
|
|
$found = false;
|
|
foreach ($dom->getElementsByTagName('Config') as $config) {
|
|
if ($config->getAttribute('Target') === $target) {
|
|
set_config_value($config, $value);
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!$found) fail("Override target not present in template: $target");
|
|
}
|
|
|
|
// Some CA templates omit Mode on variables. Unraid's Docker manager reads
|
|
// the attribute unconditionally, so normalize it before saving the user's
|
|
// template to avoid PHP warnings while preserving an empty mode.
|
|
foreach ($dom->getElementsByTagName('Config') as $config) {
|
|
if (!$config->hasAttribute('Mode')) {
|
|
$config->setAttribute('Mode', '');
|
|
}
|
|
}
|
|
|
|
$dom->formatOutput = true;
|
|
if ($dom->save($template_path) === false) fail('Unable to write Unraid user template');
|
|
chmod($template_path, 0600);
|
|
out("Community Applications template saved: $name");
|
|
rebuild_container($name, true, $start);
|
|
out('Container is managed by the Unraid user template');
|
|
}
|
|
|
|
// ── Main ─────────────────────────────────────────────────────────────────
|
|
$argv = $_SERVER['argv'];
|
|
$action = $argv[1] ?? '';
|
|
$arg1 = $argv[2] ?? '';
|
|
$arg2 = $argv[3] ?? '';
|
|
$arg3 = $argv[4] ?? '';
|
|
|
|
switch ($action) {
|
|
case 'create':
|
|
if (!$arg1) fail("Missing template name");
|
|
out("Creating container from template: $arg1");
|
|
rebuild_container($arg1, true);
|
|
break;
|
|
|
|
case 'modify':
|
|
if (!$arg1 || !$arg2 || !$arg3) fail("Usage: modify <container> <field> <value>");
|
|
modify_template($arg1, $arg2, $arg3);
|
|
break;
|
|
|
|
case 'update':
|
|
if (!$arg1) fail("Missing container name");
|
|
out("Updating container: $arg1");
|
|
rebuild_container($arg1, true);
|
|
break;
|
|
|
|
case 'update-verified-batch':
|
|
if (!$arg1) fail('Missing container names');
|
|
update_verified_batch($arg1);
|
|
break;
|
|
|
|
case 'rebuild':
|
|
if (!$arg1) fail("Missing container name");
|
|
out("Rebuilding container: $arg1");
|
|
rebuild_container($arg1, false);
|
|
break;
|
|
|
|
case 'ca-install':
|
|
if (!$arg1 || !$arg2) fail('Usage: ca-install <template_url> <container_name> <overrides_json> <start:true|false>');
|
|
ca_install($arg1, $arg2, $arg3 ?: '{}', ($argv[5] ?? 'false') === 'true');
|
|
break;
|
|
|
|
default:
|
|
fail("Unknown action: $action. Usage: create|modify|update|update-verified-batch|rebuild|ca-install");
|
|
}
|