The readiness probe wrapped the remote command in raw double quotes with
manually backslash-escaped inner quotes. shell_exec() runs its command
through an extra local `sh -c` layer beyond the ssh invocation itself, and
because the remote command was double-quoted (not single-quoted/opaque),
that extra local layer expanded the $(...)/${...} substitutions using
HOST1's own environment before ssh ever sent anything to the remote host.
Confirmed live: the exact same command run directly (one shell layer)
returned the correct remote SCRIPTS_DIR; run through an extra sh -c layer
(matching shell_exec's real behavior) it silently evaluated everything
against HOST1's local varaverk.cfg instead, producing an empty probe result
every time — so every push silently reported "plugin not installed" even
though HOST2 was fully installed and reachable.
Fix: build the remote command as a plain string and escapeshellarg() it as
a whole, same pattern vv_pt_ssh() already used safely elsewhere. Verified
live — probe now returns HOST2's real SCRIPTS_DIR and the master.conf push
lands with a matching checksum on both hosts.
429 lines
18 KiB
PHP
429 lines
18 KiB
PHP
<?php
|
|
// Config file parser and writer.
|
|
// Reads master.conf and the appropriate host*.conf based on running host.
|
|
|
|
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
|
|
|
|
$_vv_cfg = @parse_ini_file(PLUGIN_CFG) ?: [];
|
|
define('SCRIPTS_DIR', $_vv_cfg['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
|
|
define('CONF_DIR', SCRIPTS_DIR . '/Configurations');
|
|
define('DEPLOY_DIR', SCRIPTS_DIR . '/Deployment');
|
|
define('DATA_DIR', SCRIPTS_DIR . '/data');
|
|
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
|
|
define('LOG_DIR', '/var/log/varaverk');
|
|
// User-authored custom scripts (scheduler page "+ Create Script") — kept outside the git
|
|
// repo entirely, alongside the User Scripts plugin's own storage. Any *.sh file placed
|
|
// directly in this folder is auto-detected and listed — it doesn't have to be created
|
|
// through the page's editor.
|
|
define('CUSTOM_SCRIPTS_DIR', $_vv_cfg['CUSTOM_SCRIPTS_DIR'] ?? '/boot/config/plugins/user.scripts/Varaverk/Scripts');
|
|
unset($_vv_cfg);
|
|
|
|
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
|
|
define('VV_CACHE_DIR', '/tmp/vv_cache');
|
|
|
|
// Read the setup state file into a key=>value array.
|
|
function vv_setup_state_read(): array {
|
|
$out = [];
|
|
foreach (file(VV_SETUP_STATE_FILE) ?: [] as $line) {
|
|
[$k, $v] = explode('=', trim($line), 2) + ['', ''];
|
|
if ($k !== '') $out[$k] = $v;
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
// Write the setup state file (creates or overwrites).
|
|
function vv_setup_state_write(array $data): void {
|
|
$content = '';
|
|
foreach ($data as $k => $v) $content .= "$k=$v\n";
|
|
file_put_contents(VV_SETUP_STATE_FILE, $content);
|
|
}
|
|
|
|
// Push the setup state file to all remote hosts via scp.
|
|
// Reads the remote's varaverk.cfg to find their actual SCRIPTS_DIR (handles appdata mode).
|
|
function vv_push_setup_state(): void {
|
|
if (!file_exists(VV_SETUP_STATE_FILE)) return;
|
|
$myHostId = vv_detect_host();
|
|
$vars = vv_conf_vars();
|
|
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
|
|
if (!$sshKey || !file_exists($sshKey)) return;
|
|
|
|
$master = vv_read_conf_raw('master.conf');
|
|
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
|
|
$seen = [];
|
|
foreach ($m[1] as $i => $hostKey) {
|
|
$hostId = strtolower($hostKey);
|
|
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
|
|
$seen[$hostId] = true;
|
|
$hostname = trim($m[2][$i]);
|
|
if (!$hostname) continue;
|
|
$ip = vv_resolve_tailscale_ip($hostname);
|
|
if (!$ip) continue;
|
|
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
|
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
|
|
|
|
// Get remote SCRIPTS_DIR from varaverk.cfg — handles appdata mode on remote.
|
|
// Falls back to the default install path if varaverk.cfg is absent (pre-install).
|
|
$cfgRaw = trim(shell_exec($sshBase . ' "cat /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null"') ?: '');
|
|
$remoteSD = '/boot/config/plugins/varaverk';
|
|
foreach (explode("\n", $cfgRaw) as $line) {
|
|
if (str_starts_with(trim($line), 'SCRIPTS_DIR=')) {
|
|
$remoteSD = trim(substr(trim($line), strlen('SCRIPTS_DIR=')), '"\'');
|
|
break;
|
|
}
|
|
}
|
|
$remoteStatePath = $remoteSD . '/State_Files/varaverk_setup.db';
|
|
shell_exec($sshBase . ' "mkdir -p ' . escapeshellarg(dirname($remoteStatePath)) . '" 2>/dev/null');
|
|
$dest = escapeshellarg('root@' . $ip . ':' . $remoteStatePath);
|
|
exec('scp -i ' . escapeshellarg($sshKey)
|
|
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
|
. ' ' . escapeshellarg(VV_SETUP_STATE_FILE) . ' ' . $dest . ' 2>&1');
|
|
}
|
|
}
|
|
|
|
// Push master.conf to all remote hosts via scp after a local save.
|
|
// Returns one result entry per remote found in master.conf.
|
|
// Silently returns [] on non-owner hosts (no SSH key, no remote access).
|
|
function vv_push_master_conf(): array {
|
|
$myHostId = vv_detect_host();
|
|
$vars = vv_conf_vars();
|
|
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
|
|
if (!$sshKey || !file_exists($sshKey)) return [];
|
|
|
|
$localPath = CONF_DIR . '/master.conf';
|
|
$master = vv_read_conf_raw('master.conf');
|
|
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
|
|
|
|
$results = [];
|
|
$seen = [];
|
|
foreach ($m[1] as $i => $hostKey) {
|
|
$hostId = strtolower($hostKey);
|
|
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
|
|
$seen[$hostId] = true;
|
|
|
|
$hostname = trim($m[2][$i]);
|
|
$ip = vv_resolve_tailscale_ip($hostname);
|
|
if (!$ip) {
|
|
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false, 'error' => 'Tailscale IP not found'];
|
|
continue;
|
|
}
|
|
|
|
// Single SSH call: get remote SCRIPTS_DIR and verify plugin is installed,
|
|
// Configurations/ exists, and master.conf is already present.
|
|
// Any missing piece means the remote isn't ready — skip rather than push blind.
|
|
// Remote command built as one PHP string and escapeshellarg()'d whole — shell_exec()
|
|
// adds its own `sh -c` layer locally, so a bare double-quoted string here would let
|
|
// the $(...)/${...} substitutions expand on HOST1 before ssh ever sees them, instead
|
|
// of on the remote host. escapeshellarg() keeps it opaque until the remote shell runs it.
|
|
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
|
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
|
|
$remoteCmd = 'cfg=$(grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null)'
|
|
. ' && sd=$(echo "$cfg" | grep -oP \'(?<=SCRIPTS_DIR=")[^"]+\')'
|
|
. ' && test -d "${sd}/Configurations"'
|
|
. ' && test -f "${sd}/Configurations/master.conf"'
|
|
. ' && echo "$sd"';
|
|
$probe = trim(shell_exec($sshBase . ' ' . escapeshellarg($remoteCmd)) ?: '');
|
|
|
|
if ($probe === '') {
|
|
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false,
|
|
'error' => 'plugin not installed, dir missing, or master.conf absent — skipped'];
|
|
continue;
|
|
}
|
|
|
|
$remoteConf = rtrim($probe, '/') . '/Configurations';
|
|
$dest = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
|
|
$cmd = 'scp -i ' . escapeshellarg($sshKey)
|
|
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
|
. ' ' . escapeshellarg($localPath) . ' ' . $dest . ' 2>&1';
|
|
exec($cmd, $out, $rc);
|
|
$results[] = [
|
|
'host' => $hostKey,
|
|
'ok' => $rc === 0,
|
|
'error' => $rc !== 0 ? implode('; ', $out) : '',
|
|
];
|
|
}
|
|
return $results;
|
|
}
|
|
|
|
function vv_get_hostname(): string {
|
|
return trim(shell_exec('hostname -s') ?: '');
|
|
}
|
|
|
|
// Mirror of common.sh resolve_tailscale_ip(): tries `tailscale ip -4` first (Tailscale manages
|
|
// the mapping so this survives IP changes), falls back to parsing `tailscale status` text.
|
|
function vv_resolve_tailscale_ip(string $hostname): string {
|
|
$h = strtolower($hostname);
|
|
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: '');
|
|
if ($ip) return $ip;
|
|
$out = shell_exec('tailscale status 2>/dev/null') ?: '';
|
|
foreach (explode("\n", $out) as $line) {
|
|
$cols = preg_split('/\s+/', trim($line));
|
|
if (isset($cols[1]) && stripos($cols[1], $h . '.') === 0) return $cols[0];
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function vv_detect_host(): string {
|
|
// Reads master.conf for HOST1="name" (or HOST1_NAME="name") and matches running hostname.
|
|
// Returns 'host1', 'host2', 'host3', ... or 'unknown'. Works for any number of hosts.
|
|
$master = vv_read_conf_raw('master.conf');
|
|
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
|
|
$hostname = vv_get_hostname();
|
|
foreach ($m[1] as $i => $key) {
|
|
if (strcasecmp($hostname, trim($m[2][$i])) === 0) return strtolower($key);
|
|
}
|
|
return 'unknown';
|
|
}
|
|
|
|
function vv_is_owner(): bool {
|
|
return vv_detect_host() === 'host1';
|
|
}
|
|
|
|
function vv_read_conf_raw(string $filename): string {
|
|
$path = CONF_DIR . '/' . $filename;
|
|
return file_exists($path) ? file_get_contents($path) : '';
|
|
}
|
|
|
|
function vv_write_conf_raw(string $filename, string $content): bool {
|
|
$path = CONF_DIR . '/' . $filename;
|
|
$tmp = $path . '.vv.tmp';
|
|
if (file_put_contents($tmp, $content) === false) return false;
|
|
return rename($tmp, $path);
|
|
}
|
|
|
|
function vv_get_conf_files(): array {
|
|
// Returns conf files this host is allowed to view/edit
|
|
$host = vv_detect_host();
|
|
$files = [];
|
|
if ($host === 'host1') {
|
|
// Owner sees master.conf + their own host conf
|
|
$files[] = 'master.conf';
|
|
$files[] = 'host1.conf';
|
|
} elseif (preg_match('/^host(\d+)$/', $host)) {
|
|
// Any other numbered host sees only their own conf
|
|
$files[] = $host . '.conf';
|
|
} else {
|
|
// Unknown host — show all for dev/debug
|
|
foreach (glob(CONF_DIR . '/*.conf') as $f) {
|
|
$files[] = basename($f);
|
|
}
|
|
}
|
|
return $files;
|
|
}
|
|
|
|
// Parse conf into key=>value map for $VAR substitution in docs
|
|
function vv_conf_vars(): array {
|
|
$vars = [];
|
|
$files = ['master.conf'];
|
|
$host = vv_detect_host();
|
|
if (preg_match('/^host\d+$/', $host)) $files[] = $host . '.conf';
|
|
|
|
foreach ($files as $f) {
|
|
$raw = vv_read_conf_raw($f);
|
|
// Match: VAR_NAME="value" or VAR_NAME=value (no quotes)
|
|
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
|
|
foreach ($m[1] as $i => $key) {
|
|
$vars[$key] = str_replace('\\$', '$', trim($m[2][$i]));
|
|
}
|
|
}
|
|
// Resolve bash variable references — bash expands ${VAR} at runtime; PHP reads them literally.
|
|
// Pass 1: ${SCRIPTS_DIR} from the PHP-computed constant (other vars depend on it).
|
|
// Pass 2: ${VAR} using now-resolved values from within the same conf set.
|
|
foreach ($vars as $k => &$v) {
|
|
if (is_string($v)) $v = str_replace('${SCRIPTS_DIR}', SCRIPTS_DIR, $v);
|
|
}
|
|
foreach ($vars as $k => &$v) {
|
|
if (is_string($v) && str_contains($v, '${')) {
|
|
$v = preg_replace_callback('/\$\{([A-Z0-9_]+)\}/', function ($m) use ($vars) {
|
|
return $vars[$m[1]] ?? $m[0];
|
|
}, $v);
|
|
}
|
|
}
|
|
unset($v);
|
|
return $vars;
|
|
}
|
|
|
|
// Query the Unraid GraphQL API for a given host.
|
|
// For the local host queries http://localhost/graphql; for remote hosts uses the Tailscale IP.
|
|
// $apiKey may be passed explicitly (needed when querying a remote host from the local host,
|
|
// since vv_conf_vars() only loads the current host's conf file).
|
|
// Returns the decoded 'data' object on success, null on any failure.
|
|
// Debug log written to /tmp/vv_api_debug.json on failure.
|
|
function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, string $apiKey = ''): ?array {
|
|
$vars = vv_conf_vars();
|
|
$key = $apiKey ?: ($vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '');
|
|
if (!$key) return null;
|
|
|
|
$myHostId = vv_detect_host();
|
|
if (strtolower($hostId) === strtolower($myHostId)) {
|
|
$url = 'http://localhost/graphql';
|
|
} else {
|
|
$hostname = $vars[strtoupper($hostId)] ?? '';
|
|
if (!$hostname) return null;
|
|
$ip = vv_resolve_tailscale_ip($hostname);
|
|
if (!$ip) return null;
|
|
$url = "http://{$ip}/graphql";
|
|
}
|
|
|
|
$body = json_encode(['query' => $gql]);
|
|
|
|
// Use curl (preferred — doesn't require allow_url_fopen, better error handling).
|
|
if (function_exists('curl_init')) {
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
|
CURLOPT_POSTFIELDS => $body,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => $timeoutSec,
|
|
CURLOPT_CONNECTTIMEOUT => 3,
|
|
CURLOPT_FOLLOWLOCATION => false,
|
|
]);
|
|
$resp = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$curlErr = curl_error($ch);
|
|
curl_close($ch);
|
|
} else {
|
|
// Fallback to file_get_contents if curl is unavailable.
|
|
$ctx = stream_context_create(['http' => [
|
|
'method' => 'POST',
|
|
'header' => "Content-Type: application/json\r\nx-api-key: {$key}",
|
|
'content' => $body,
|
|
'timeout' => $timeoutSec,
|
|
'ignore_errors' => true,
|
|
]]);
|
|
$resp = @file_get_contents($url, false, $ctx);
|
|
$httpCode = $resp !== false ? 200 : 0;
|
|
$curlErr = '';
|
|
}
|
|
|
|
if ($resp === false || $resp === '' || ($httpCode !== 0 && $httpCode !== 200)) {
|
|
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
|
|
'ts' => time(),
|
|
'host' => $hostId,
|
|
'url' => $url,
|
|
'http_code' => $httpCode,
|
|
'curl_err' => $curlErr,
|
|
'response' => substr((string)$resp, 0, 800),
|
|
], JSON_PRETTY_PRINT));
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode((string)$resp, true);
|
|
|
|
// If the API returned GraphQL errors, log them for diagnosis.
|
|
if (!empty($decoded['errors'])) {
|
|
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
|
|
'ts' => time(),
|
|
'host' => $hostId,
|
|
'url' => $url,
|
|
'http_code' => $httpCode,
|
|
'errors' => $decoded['errors'],
|
|
'data' => $decoded['data'] ?? null,
|
|
], JSON_PRETTY_PRINT));
|
|
}
|
|
|
|
// data key present (even if null means query ran but returned nothing useful).
|
|
return array_key_exists('data', $decoded ?? []) ? $decoded['data'] : null;
|
|
}
|
|
|
|
// ── File-based API cache (/tmp/vv_cache — tmpfs, cleared on reboot) ───────────
|
|
|
|
// Read a cached payload. Returns null if missing or older than $maxAge seconds.
|
|
function vv_cache_read(string $key, int $maxAge = 90): ?array {
|
|
$f = VV_CACHE_DIR . '/' . $key . '.json';
|
|
if (!file_exists($f) || (time() - filemtime($f)) > $maxAge) return null;
|
|
$raw = file_get_contents($f);
|
|
return $raw ? (json_decode($raw, true) ?: null) : null;
|
|
}
|
|
|
|
// Write a payload atomically (tmp + rename) so readers never see a partial file.
|
|
function vv_cache_write(string $key, array $data): void {
|
|
if (!is_dir(VV_CACHE_DIR)) @mkdir(VV_CACHE_DIR, 0755, true);
|
|
$f = VV_CACHE_DIR . '/' . $key . '.json';
|
|
$tmp = $f . '.tmp';
|
|
file_put_contents($tmp, json_encode($data));
|
|
rename($tmp, $f);
|
|
}
|
|
|
|
// ── Shared utility functions (used across include/ and api/ files) ────────────
|
|
|
|
// Format seconds into "2d 3h 15m".
|
|
function vv_format_uptime(int $seconds): string {
|
|
$d = intdiv($seconds, 86400);
|
|
$h = intdiv($seconds % 86400, 3600);
|
|
$m = intdiv($seconds % 3600, 60);
|
|
return ($d ? "{$d}d " : '') . ($h ? "{$h}h " : '') . "{$m}m";
|
|
}
|
|
|
|
// Parse a scalar value from raw conf text. Matches KEY="value" or KEY=value.
|
|
// Identical logic was previously duplicated as vv_arr_scalar / vv_wd_scalar /
|
|
// vv_fb_scalar / vv_media_conf_scalar — all reduce to this one regex.
|
|
function vv_parse_conf_scalar(string $raw, string $key): string {
|
|
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
|
|
? trim($m[1]) : '';
|
|
}
|
|
|
|
// Parse a key=value state file (e.g. fallback_state.db, partnership_state.db).
|
|
// Returns ['key' => 'value', ...]. Lines without '=' are ignored.
|
|
function vv_parse_kv_db(string $text): array {
|
|
$out = [];
|
|
foreach (explode("\n", $text) as $line) {
|
|
$line = trim($line);
|
|
if ($line === '' || $line[0] === '#') continue;
|
|
[$k, $v] = array_pad(explode('=', $line, 2), 2, '');
|
|
if ($k !== '') $out[trim($k)] = trim($v);
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
// All configured hosts from master.conf as ['host1' => 'hostname', ...].
|
|
// Canonical version — previously duplicated as vv_arr_known_hosts / vv_fb_known_hosts.
|
|
function vv_known_hosts(): array {
|
|
$vars = vv_conf_vars();
|
|
$hosts = [];
|
|
foreach ($vars as $k => $v) {
|
|
if (preg_match('/^HOST(\d+)$/', $k, $m) && $v !== '') {
|
|
$hosts['host' . $m[1]] = $v;
|
|
}
|
|
}
|
|
ksort($hosts);
|
|
return $hosts ?: ['host1' => 'HOST1'];
|
|
}
|
|
|
|
// Create (or overwrite) the Varaverk Unraid API key and write it into host conf.
|
|
// Returns ['ok'=>true,'key_preview'=>'...'] or ['ok'=>false,'error'=>'...'].
|
|
function vv_auto_create_api_key(string $hostId, string $confFile): array {
|
|
$script = SCRIPTS_DIR . '/Plugin/unraid/System_Essentials/unraid_api_key_renew.sh';
|
|
if (!file_exists($script)) {
|
|
return ['ok' => false, 'error' => 'unraid_api_key_renew.sh not found'];
|
|
}
|
|
exec('bash ' . escapeshellarg($script) . ' 2>&1', $out, $rc);
|
|
if ($rc !== 0) {
|
|
$msg = implode(' ', array_filter(array_map('trim', $out)));
|
|
return ['ok' => false, 'error' => $msg ?: 'Script failed'];
|
|
}
|
|
$varName = strtoupper($hostId) . '_UNRAID_API_KEY';
|
|
$raw = vv_read_conf_raw($confFile);
|
|
preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*"([^"]+)"/m', $raw, $m);
|
|
$key = $m[1] ?? '';
|
|
return ['ok' => true, 'key_preview' => $key ? substr($key, 0, 8) . '...' . substr($key, -4) : 'registered'];
|
|
}
|
|
|
|
// Build a bash command that reads a state file from the REMOTE host's State_Files/.
|
|
// Reads the remote's varaverk.cfg to resolve their SCRIPTS_DIR (may differ from ours
|
|
// when the remote is in appdata mode). Falls back to the internal plugin path.
|
|
function vv_remote_state_cmd(string $filename): string {
|
|
$fn = basename($filename);
|
|
return 'sd=$(grep -m1 SCRIPTS_DIR= /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null'
|
|
. ' | cut -d\'"\' -f2); cat "${sd:-/boot/config/plugins/varaverk}/State_Files/' . $fn . '" 2>/dev/null';
|
|
}
|
|
|
|
// Local LAN IP via routing table — static-cached per request.
|
|
// Previously duplicated in include/docker_folders.php and inline in include/docker.php.
|
|
function vv_local_ip(): string {
|
|
static $ip = null;
|
|
if ($ip !== null) return $ip;
|
|
$ip = trim(shell_exec("ip route get 8.8.8.8 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++)if(\$i==\"src\")print \$(i+1)}'") ?? '');
|
|
return $ip;
|
|
}
|