diff --git a/emhttp/plugins/dynamix.docker.manager/DockerContainers.page b/emhttp/plugins/dynamix.docker.manager/DockerContainers.page index 053c6d9585..6e9f853f3a 100755 --- a/emhttp/plugins/dynamix.docker.manager/DockerContainers.page +++ b/emhttp/plugins/dynamix.docker.manager/DockerContainers.page @@ -3,7 +3,7 @@ Title="Docker Containers" Tag="cubes" Cond="is_file('/var/run/dockerd.pid')" Markdown="false" -Nchan="docker_load" +Nchan="docker_load,tailscale_status" Tabs="false" --- '+data[1]+'<\/script>'); + (new Function(data[1]))(); $('.iconstatus').each(function(){ if ($(this).hasClass('stopped')) $('div.'+$(this).prop('id')).hide(); }); @@ -165,7 +178,7 @@ function loadlist(init) { } listview(); $('div.spinner.fixed').hide('slow'); - if (data[2]==1) {$('#busy').show(); setTimeout(loadlist,5000);} else if ($('#busy').is(':visible')) {$('#busy').hide(); setTimeout(loadlist,3000);} + if (data[2]==1) {$('#busy').show(); scheduleNextLoad(5000);} else if ($('#busy').is(':visible')) {$('#busy').hide(); scheduleNextLoad(3000);} function resizeTableColumns() { // Handle table header fixed positioning after resize $('#docker_containers thead,#docker_containers tbody').removeClass('fixed'); @@ -208,6 +221,9 @@ dockerload.on('message', function(msg){ $('#cpu-'+id[0]).css('width',w1); } }); +// We don't need the event from the subscription. It's just to keep the worker running. +var tailscaleStatus = new NchanSubscriber('/sub/tailscalestatus',{subscriber:'websocket'}); +tailscaleStatus.on('message', function(msg){}); $(function() { $('.advancedview').switchButton({labels_placement:'left', on_label:"_(Advanced View)_", off_label:"_(Basic View)_", checked:$.cookie('docker_listview_mode')=='advanced'}); $('.advancedview').change(function(){ @@ -219,6 +235,7 @@ $(function() { $.removeCookie('lockbutton'); loadlist(true); dockerload.start(); + tailscaleStatus.start(); }); diff --git a/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php b/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php index e7277b3375..fc51e1ae2a 100755 --- a/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php +++ b/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php @@ -155,7 +155,7 @@ public function getUserTemplatePath($Container) { return $match ?: "$dir/$target"; } - public function downloadTemplates($Dest=null, $Urls=null) { + public function downloadTemplates($Dest=null, $Urls=null) { /* Don't download any templates. Leave code in place for future reference. */ /* remove existing limetech templates that are all not valid */ exec("rm -rf /boot/config/plugins/dockerMan/templates/limetech"); @@ -309,17 +309,11 @@ private function getControlURL(&$ct, $myIP, $WebUI) { } private function getTailscaleJson($name) { - $TS_raw = []; - exec("docker exec -i ".$name." /bin/sh -c \"tailscale status --peers=false --json\" 2>/dev/null", $TS_raw); - if (!empty($TS_raw)) { - $TS_raw = implode("\n", $TS_raw); - return json_decode($TS_raw, true); - } - return ''; + return DockerUtil::tailscaleStatus($name) ?: ''; } public function getAllInfo($reload=false,$com=true,$communityApplications=false) { - global $driver, $dockerManPaths; + global $driver, $dockerManPaths, $docroot; $DockerClient = new DockerClient(); $DockerUpdate = new DockerUpdate(); $host = DockerUtil::host(); @@ -335,11 +329,22 @@ public function getAllInfo($reload=false,$com=true,$communityApplications=false) $tmp['autostart'] = in_array($name,$autoStart); $tmp['cpuset'] = $ct['CPUset']; $tmp['url'] = $ct['Url'] ?? $tmp['url'] ?? ''; - // read docker label for WebUI & Icon - if (isset($ct['Icon'])) $tmp['icon'] = $ct['Icon']; + // Docker label for Shell. if (isset($ct['Shell'])) $tmp['shell'] = $ct['Shell']; + // Pass the label URL as a download fallback only when there's no + // usable cached file. Otherwise, we'll check if the URL is a file, + // obviously fail, and search through every template in /boot + // looking for a match. Bad. + $labelIconUrl = $ct['Icon'] ?? null; if (!$communityApplications) { - if (!is_file($tmp['icon']) || $reload) $tmp['icon'] = $this->getIcon($image,$name,$tmp['icon']); + $iconExists = !empty($tmp['icon']) + && (is_file($tmp['icon']) || is_file($docroot . $tmp['icon'])); + if (!$iconExists || $reload) { + $tmp['icon'] = $this->getIcon($image, $name, $labelIconUrl ?: ($tmp['icon'] ?? '')); + // Explicitly return the fallback asset, so that subsequent polls see + // the local file instead of rerunning the expensive template scan + if (empty($tmp['icon'])) $tmp['icon'] = '/plugins/dynamix.docker.manager/images/question.png'; + } } if ($ct['Running']) { $port = &$ct['Ports'][0]; @@ -629,7 +634,7 @@ public function inspectLocalVersion($image) { $DockerClient = new DockerClient(); $inspect = $DockerClient->getDockerJSON('/images/'.$image.'/json'); if (empty($inspect['RepoDigests'])) return null; - + $repoDigest = $inspect['RepoDigests'][array_key_last($inspect['RepoDigests'])]; $shaPos = strpos($repoDigest, '@sha256:'); if ($shaPos === false) return null; @@ -1186,9 +1191,11 @@ public static function myIP($name, $version=4) { } public static function driver() { + static $cached; + if ($cached !== null) return $cached; $list = []; foreach (static::docker("network ls --format='{{.Name}}={{.Driver}}'",true) as $network) {[$net,$driver] = array_pad(explode('=',$network),2,''); $list[$net] = $driver;} - return $list; + return $cached = $list; } public static function custom() { @@ -1211,18 +1218,45 @@ public static function ctMap($ct, $type='Name') { } public static function port() { - if (lan_port('br0')) return 'br0'; - if (lan_port('bond0')) return 'bond0'; - if (lan_port('eth0')) return 'eth0'; - if (lan_port('wlan0')) return 'wlan0'; - return ''; + static $cached; + if ($cached !== null) return $cached; + if (lan_port('br0')) return $cached = 'br0'; + if (lan_port('bond0')) return $cached = 'bond0'; + if (lan_port('eth0')) return $cached = 'eth0'; + if (lan_port('wlan0')) return $cached = 'wlan0'; + return $cached = ''; } public static function host() { + static $cached; + if ($cached !== null) return $cached; $port = static::port(); - if (!$port) return ''; + if (!$port) return $cached = ''; $port = lan_port($port,true)!=1 && lan_port('wlan0') ? 'wlan0' : $port; - return exec("ip -br -4 addr show $port scope global | sed -r 's/\/[0-9]+//g' | awk '{print $3;exit}'"); + return $cached = exec("ip -br -4 addr show $port scope global | sed -r 's/\/[0-9]+//g' | awk '{print $3;exit}'"); + } + + const TAILSCALE_CACHE_FILE = '/var/lib/docker/unraid-tailscale-status.json'; + + public static function tailscaleCache(): array { + static $cached; + if (is_array($cached)) return $cached; + if (!is_file(self::TAILSCALE_CACHE_FILE)) return $cached = []; + $data = @json_decode(@file_get_contents(self::TAILSCALE_CACHE_FILE), true); + return $cached = (is_array($data) ? $data : []); + } + + public static function tailscaleStatus(string $name) { + return static::tailscaleCache()['containers'][$name]['data'] ?? null; + } + + public static function tailscaleDerpMap() { + return static::tailscaleCache()['derp'] ?? null; + } + + public static function tailscaleLatestVersion(): ?string { + $v = static::tailscaleCache()['version'] ?? null; + return is_array($v) ? ($v['TarballsVersion'] ?? null) : null; } } ?> diff --git a/emhttp/plugins/dynamix.docker.manager/include/DockerContainers.php b/emhttp/plugins/dynamix.docker.manager/include/DockerContainers.php index 8ed97abade..ca2d0918dc 100644 --- a/emhttp/plugins/dynamix.docker.manager/include/DockerContainers.php +++ b/emhttp/plugins/dynamix.docker.manager/include/DockerContainers.php @@ -51,54 +51,11 @@ $autostart = (array)@file($autostart_file,FILE_IGNORE_NEW_LINES); $names = array_map('var_split',$autostart); -// Grab Tailscale json from container function tailscale_stats($name) { - exec("docker exec -i ".$name." /bin/sh -c \"tailscale status --json | jq '{Self: .Self, ExitNodeStatus: .ExitNodeStatus, Version: .Version}'\" 2>/dev/null", $TS_stats); - if (!empty($TS_stats)) { - $TS_stats = implode("\n", $TS_stats); - return json_decode($TS_stats, true); - } - return ''; -} - -// Download Tailscal JSON and return Array, refresh file if older than 24 hours -function tailscale_json_dl($file, $url) { - $dl_status = 0; - if (!is_dir('/tmp/tailscale')) { - mkdir('/tmp/tailscale', 0777, true); - } - if (!file_exists($file)) { - exec("wget -T 3 -q -O ".$file." ".$url, $output, $dl_status); - } else { - $fileage = time() - filemtime($file); - if ($fileage > 86400) { - unlink($file); - exec("wget -T 3 -q -O ".$file." ".$url, $output, $dl_status); - } - } - if ($dl_status === 0) { - return json_decode(@file_get_contents($file), true); - } elseif ($dl_status === 0 && is_file($file)) { - return json_decode(@file_get_contents($file), true); - } else { - unlink($file); - return ''; - } -} - -// Grab Tailscale DERP map JSON -$TS_derp_url = 'https://login.tailscale.com/derpmap/default'; -$TS_derp_file = '/tmp/tailscale/tailscale-derpmap.json'; -$TS_derp_list = tailscale_json_dl($TS_derp_file, $TS_derp_url); - -// Grab Tailscale version JSON -$TS_version_url = 'https://pkgs.tailscale.com/stable/?mode=json'; -$TS_version_file = '/tmp/tailscale/tailscale-latest-version.json'; -// Extract tarbal version string -$TS_latest_version = tailscale_json_dl($TS_version_file, $TS_version_url); -if (!empty($TS_latest_version)) { - $TS_latest_version = $TS_latest_version["TarballsVersion"]; + return DockerUtil::tailscaleStatus($name) ?: ''; } +$TS_derp_list = DockerUtil::tailscaleDerpMap() ?: ''; +$TS_latest_version = DockerUtil::tailscaleLatestVersion() ?: ''; function my_lang_time($text) { [$number, $text] = my_explode(' ',$text,2); @@ -163,7 +120,7 @@ function my_lang_log($text) { } elseif (!isset($ct['Ports']['vlan']) || strpos($ct['NetworkMode'],'container:')!==false) { foreach ($ct['Ports'] as $port) { if (_var($port,'PublicPort') && _var($port,'Driver') == 'bridge') { - if (_var($port, "HostIp") != "") $hostip = _var($port, "HostIp"); else $hostip = $host; + if (_var($port, "HostIp") != "") $hostip = _var($port, "HostIp"); else $hostip = $host; $ports_external[] = sprintf('%s:%s', $hostip, strtoupper(_var($port,'PublicPort'))); } if ((!isset($ct['Networks']['host'])) || (!isset($ct['Networks']['vlan']))) { diff --git a/emhttp/plugins/dynamix.docker.manager/nchan/tailscale_status b/emhttp/plugins/dynamix.docker.manager/nchan/tailscale_status new file mode 100755 index 0000000000..1be0697ead --- /dev/null +++ b/emhttp/plugins/dynamix.docker.manager/nchan/tailscale_status @@ -0,0 +1,123 @@ +#!/usr/bin/php -q + + true, + CURLOPT_CONNECTTIMEOUT => $timeout, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_FAILONERROR => true, + ]); + $out = curl_exec($ch); + curl_close($ch); + if (!$out) return null; + $data = json_decode($out, true); + return is_array($data) ? $data : null; +} + +// Gather containers with the label "tailscale.hostname" or "tailscale.webui", since +// these are what trigger a request for status upstream +function ts_list_containers(): array { + $names = []; + foreach (['hostname', 'webui'] as $kind) { + $raw = shell_exec('docker ps --filter "label=net.unraid.docker.tailscale.' . $kind . '" --format "{{.Names}}" 2>/dev/null') ?? ''; + foreach (preg_split('/\R/', $raw, -1, PREG_SPLIT_NO_EMPTY) as $name) { + $names[trim($name)] = true; + } + } + return array_keys($names); +} + +function ts_container_status(string $name) { + $out = []; + $rc = 0; + $cmd = sprintf( + 'timeout %ds docker exec -i %s /bin/sh -c %s 2>/dev/null', + TS_EXEC_TIMEOUT_S, + escapeshellarg($name), + escapeshellarg('tailscale status --peers=false --json') + ); + exec($cmd, $out, $rc); + if ($rc !== 0 || empty($out)) return null; + $data = json_decode(implode("\n", $out), true); + return is_array($data) ? $data : null; +} + +function ts_load_state(): array { + $state = ['derp' => null, 'derp_ts' => 0, 'version' => null, 'version_ts' => 0, 'containers' => [], 'ts' => 0]; + if (is_file(TS_CACHE_FILE)) { + $existing = @json_decode(@file_get_contents(TS_CACHE_FILE), true); + if (is_array($existing)) $state = array_replace($state, array_intersect_key($existing, $state)); + } + return $state; +} + +function ts_save_state(array $state): void { + $tmp = TS_CACHE_FILE . '.tmp'; + if (@file_put_contents($tmp, json_encode($state, JSON_UNESCAPED_SLASHES)) !== false) { + @rename($tmp, TS_CACHE_FILE); + } +} + +$state = ts_load_state(); + +while (true) { + $now = time(); + + // Tailscale metadata + if ($now - ($state['derp_ts'] ?? 0) > TS_METADATA_REFRESH_S) { + $derp = ts_fetch_json(TS_DERP_URL); + if ($derp) { $state['derp'] = $derp; $state['derp_ts'] = $now; } + } + if ($now - ($state['version_ts'] ?? 0) > TS_METADATA_REFRESH_S) { + $version = ts_fetch_json(TS_VERSION_URL); + if ($version) { $state['version'] = $version; $state['version_ts'] = $now; } + } + + // Per-container status + $containers = ts_list_containers(); + $fresh = []; + foreach ($containers as $name) { + $status = ts_container_status($name); + if ($status !== null) { + $fresh[$name] = ['data' => $status, 'ts' => $now]; + } elseif (isset($state['containers'][$name]) && ($now - $state['containers'][$name]['ts']) < 60) { + $fresh[$name] = $state['containers'][$name]; + } + } + $state['containers'] = $fresh; + $state['ts'] = $now; + + ts_save_state($state); + + // Notify any watchers that the cache moved. We could push actual data here + // to get live tooltip updates, but that's overkill. A notification is all + // that we really want. + publish('tailscalestatus', json_encode(['ts' => $now]), 1, true); + + sleep(empty($containers) ? TS_IDLE_INTERVAL_S : TS_POLL_INTERVAL_S); +}