Compare commits
2
Commits
fe23c59e0c
...
81abc5adee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81abc5adee | ||
|
|
331b7b6b13 |
@@ -30,10 +30,22 @@
|
||||
// RESPONSE
|
||||
// vv_media_sessions() verbatim — a flat list of normalised sessions across all servers
|
||||
//
|
||||
// REQUEST
|
||||
// GET this host's sessions
|
||||
// GET ?scope=mesh every node's sessions, each row tagged with the host it is playing on
|
||||
//
|
||||
// Local is the default and stays the cheap path: one call per configured media server here.
|
||||
// Mesh adds one bounded SSH hop per partner and is only requested while the operator is looking
|
||||
// at the mesh view, so a dashboard left open on the default costs exactly what it did before.
|
||||
//
|
||||
// DEPENDS ON
|
||||
// include/media.php vv_media_sessions()
|
||||
// include/media.php vv_media_sessions(), vv_media_sessions_mesh()
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/media.php';
|
||||
|
||||
echo json_encode(vv_media_sessions());
|
||||
// Anything that is not the literal "mesh" is local. Fail-closed on the expensive path, matching
|
||||
// how every other toggle in this plugin reads its value.
|
||||
echo json_encode(($_GET['scope'] ?? '') === 'mesh'
|
||||
? vv_media_sessions_mesh()
|
||||
: vv_media_sessions() + ['scope' => 'local']);
|
||||
|
||||
@@ -1167,6 +1167,19 @@ mark { background: #5d4037; color: #ffcc80; border-radius: 2px; }
|
||||
.vv-chip-group-device, .vv-chip-group-res, .vv-chip-group-codec { display: contents; }
|
||||
.vv-stream-empty { color: #555; font-style: italic; font-size: 12px; margin: 4px 0; }
|
||||
.vv-stream-empty span { font-size: 11px; color: #444; }
|
||||
|
||||
/* Streams scope control. Deliberately named vv-strm-* rather than reusing a bare utility name:
|
||||
Unraid Connect injects a global Tailwind layer into every page, and a class named after a
|
||||
utility gets whatever that layer says. */
|
||||
.vv-strm-tab { background: #111; border: 1px solid #242424; color: #555; font-size: 10px;
|
||||
padding: 2px 10px; border-radius: 3px; cursor: pointer; text-transform: uppercase;
|
||||
letter-spacing: .05em; }
|
||||
.vv-strm-tab:hover { color: #bbb; border-color: #3a3a3a; }
|
||||
.vv-strm-tab.on { color: #4caf50; border-color: #2d4a2d; background: #0d1a0d; }
|
||||
/* The node a stream is playing on. Only rendered in mesh scope — in local scope every row is
|
||||
this host and the label would be noise on every line. */
|
||||
.vv-strm-node { font-size: 9px; color: #4a7a9f; border: 1px solid #24384a; background: #0d151c;
|
||||
border-radius: 2px; padding: 0 4px; margin-left: 5px; white-space: nowrap; }
|
||||
.vv-stream-row { margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #282828; }
|
||||
.vv-stream-row:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
|
||||
.vv-stream-top { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
|
||||
@@ -416,28 +416,47 @@ function vv_get_hostname(bool $flush = false): string {
|
||||
return $name;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Mirror of common.sh resolve_tailscale_ip(): resolves a hostname to a Tailscale IPv4.
|
||||
//
|
||||
// `tailscale status` is asked first, and that ordering is the whole point. It used to try
|
||||
// `tailscale ip -4` first, on the reasoning that Tailscale owns the mapping — true, but that call
|
||||
// resolves through MagicDNS, and MagicDNS does not work here: these hosts sit on separate tailnets
|
||||
// shared into each other, so the name misses and the call falls through to a system DNS lookup
|
||||
// that times out. Measured at **5.04 seconds, every call**, against 0.010s for the status parse.
|
||||
//
|
||||
// Nine files resolve peers this way — the AI RPC, node_chat, the arr collector, the media mesh
|
||||
// view — so that was five seconds added to every mesh operation on both hosts, quietly, for as
|
||||
// long as the mesh has existed. Nothing looked broken; everything was just slow.
|
||||
//
|
||||
// Order is now: exact match, unambiguous prefix match, then the DNS path bounded to two seconds as
|
||||
// a last resort. Exactness is not given up to get the speed — `tailscale status` carries the same
|
||||
// mapping `tailscale ip` would return, and an exact name match against it is exactly as precise.
|
||||
//
|
||||
// Memoised per request: a page that resolves the same partner four times paid four lookups.
|
||||
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;
|
||||
static $cache = [];
|
||||
$h = strtolower($hostname);
|
||||
if (isset($cache[$h])) return $cache[$h];
|
||||
|
||||
// Fallback: unambiguous prefix match against tailscale status (either direction) — handles
|
||||
// Unraid's 15-char NetBIOS hostname truncation vs. a longer name recorded in master.conf.
|
||||
// Only accept the match when exactly one peer could qualify; never guess between multiple
|
||||
// candidates that happen to share a prefix (e.g. server1/server10).
|
||||
$out = shell_exec('tailscale status 2>/dev/null') ?: '';
|
||||
$out = shell_exec('tailscale status 2>/dev/null') ?: '';
|
||||
$matches = [];
|
||||
foreach (explode("\n", $out) as $line) {
|
||||
$cols = preg_split('/\s+/', trim($line));
|
||||
if (!isset($cols[1])) continue;
|
||||
$peerHost = strtolower(explode('.', $cols[1])[0]);
|
||||
if (str_starts_with($peerHost, $h) || str_starts_with($h, $peerHost)) {
|
||||
$matches[] = $cols[0];
|
||||
}
|
||||
|
||||
if ($peerHost === $h) return $cache[$h] = $cols[0];
|
||||
|
||||
// Unambiguous prefix match, either direction — handles Unraid's 15-char NetBIOS hostname
|
||||
// truncation against a longer name recorded in master.conf. Only accepted when exactly one
|
||||
// peer could qualify; never a guess between candidates sharing a prefix (server1/server10).
|
||||
if (str_starts_with($peerHost, $h) || str_starts_with($h, $peerHost)) $matches[] = $cols[0];
|
||||
}
|
||||
return count($matches) === 1 ? $matches[0] : '';
|
||||
if (count($matches) === 1) return $cache[$h] = $matches[0];
|
||||
|
||||
// Only reached when status cannot decide. Bounded, because this is the path that blocks on DNS
|
||||
// when MagicDNS is unavailable — which is the normal case on this mesh.
|
||||
return $cache[$h] = trim(shell_exec('timeout 2 tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: '');
|
||||
}
|
||||
|
||||
// Cached alongside the others: this reads master.conf in full and is called by vv_conf_vars() on
|
||||
|
||||
@@ -258,6 +258,113 @@ function vv_media_sessions(): array {
|
||||
];
|
||||
}
|
||||
|
||||
// Who is watching, across the whole mesh.
|
||||
//
|
||||
// Fetched live rather than read from a cache, unlike vv_media_servers_mesh(). A server's version
|
||||
// and CPU are true for hours; a stream is true for minutes, and a "now playing" card assembled
|
||||
// from a two-hour-old file would be confidently wrong about the one thing it exists to say. The
|
||||
// cost is only paid when the operator asks for the mesh view — the card polls local by default.
|
||||
//
|
||||
// Each partner answers about itself for the reason the arr and media-server collectors do: its
|
||||
// Emby URL is http://localhost:8096, which is true there and meaningless here, and its API keys
|
||||
// never leave it. Same transport as remote_arr_cache_writer.sh, at a faster cadence, so the
|
||||
// connection is multiplexed and every hop is bounded.
|
||||
//
|
||||
// A partner that cannot be reached is reported as unreachable, never as zero streams. Those look
|
||||
// identical in a total and mean opposite things.
|
||||
function vv_media_sessions_mesh(): array {
|
||||
$me = vv_detect_host();
|
||||
$hosts = function_exists('vv_known_hosts') ? vv_known_hosts() : [$me => $me];
|
||||
|
||||
$nodes = [];
|
||||
$sessions = [];
|
||||
$names = [];
|
||||
$count = 0;
|
||||
|
||||
foreach ($hosts as $h => $hostName) {
|
||||
if ($h === $me) {
|
||||
$local = vv_media_sessions();
|
||||
$nodes[] = ['host' => $h, 'name' => $hostName, 'local' => true, 'reachable' => true,
|
||||
'server_count' => $local['server_count'], 'stream_count' => count($local['sessions'])];
|
||||
foreach ($local['sessions'] as $s) {
|
||||
$s['_host'] = $h; $s['_hostName'] = $hostName; $s['_local'] = true;
|
||||
$sessions[] = $s;
|
||||
}
|
||||
$names = array_merge($names, $local['server_names']);
|
||||
$count += $local['server_count'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$remote = vv_media_sessions_remote($h, $hostName);
|
||||
$nodes[] = ['host' => $h, 'name' => $hostName, 'local' => false,
|
||||
'reachable' => $remote !== null,
|
||||
'server_count' => $remote['server_count'] ?? 0,
|
||||
'stream_count' => $remote !== null ? count($remote['sessions']) : 0];
|
||||
if ($remote === null) continue;
|
||||
|
||||
foreach ($remote['sessions'] as $s) {
|
||||
$s['_host'] = $h; $s['_hostName'] = $hostName; $s['_local'] = false;
|
||||
$sessions[] = $s;
|
||||
}
|
||||
$names = array_merge($names, $remote['server_names']);
|
||||
$count += $remote['server_count'];
|
||||
}
|
||||
|
||||
return [
|
||||
'scope' => 'mesh',
|
||||
'nodes' => $nodes,
|
||||
'sessions' => $sessions,
|
||||
'server_names' => $names,
|
||||
'server_count' => $count,
|
||||
];
|
||||
}
|
||||
|
||||
// One partner's sessions, or null when it could not be asked.
|
||||
//
|
||||
// Null rather than an empty payload, deliberately: the caller has to be able to distinguish "that
|
||||
// node has nobody watching" from "that node did not answer", and an empty array cannot carry the
|
||||
// difference.
|
||||
function vv_media_sessions_remote(string $host, string $hostName): ?array {
|
||||
$vars = vv_conf_vars();
|
||||
$sshKey = $vars[strtoupper(vv_detect_host()) . '_SSH_KEY'] ?? '';
|
||||
if (!$sshKey || !is_file($sshKey)) return null;
|
||||
|
||||
$ip = vv_resolve_tailscale_ip($hostName);
|
||||
if (!$ip) return null;
|
||||
|
||||
// The WebGUI symlink, which is where Unraid serves the plugin from on every node whatever its
|
||||
// storage mode — the same path remote_arr_cache_writer.sh uses, and not a guess about where
|
||||
// the partner installed itself.
|
||||
$php = 'php -r ' . escapeshellarg(
|
||||
"require_once '/usr/local/emhttp/plugins/varaverk/include/media.php';"
|
||||
. " echo json_encode(vv_media_sessions());"
|
||||
);
|
||||
|
||||
// ControlPersist because the card polls this every 12 seconds while the mesh view is open, and
|
||||
// a fresh handshake per poll per partner is most of the cost. Timeouts are short: a dark
|
||||
// partner must render as unreachable quickly, not hold the whole card.
|
||||
$sock = rtrim(VV_CACHE_ROOT, '/') . '/ssh';
|
||||
if (!is_dir($sock)) @mkdir($sock, 0700, true);
|
||||
|
||||
$cmd = 'timeout 8 ssh -i ' . escapeshellarg($sshKey)
|
||||
. ' -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=5'
|
||||
. ' -o ControlMaster=auto -o ControlPersist=60s'
|
||||
. ' -o ControlPath=' . escapeshellarg($sock . '/media-%h')
|
||||
. ' root@' . escapeshellarg($ip) . ' ' . escapeshellarg($php) . ' 2>/dev/null';
|
||||
|
||||
$raw = shell_exec($cmd);
|
||||
if (!$raw) return null;
|
||||
|
||||
$d = json_decode(trim($raw), true);
|
||||
if (!is_array($d) || !isset($d['sessions'])) return null;
|
||||
|
||||
return [
|
||||
'sessions' => is_array($d['sessions']) ? $d['sessions'] : [],
|
||||
'server_names' => is_array($d['server_names'] ?? null) ? $d['server_names'] : [],
|
||||
'server_count' => (int)($d['server_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
// ── What each media server is actually doing ──────────────────────────────────────────────────
|
||||
// Two halves that only mean something together. The application knows its version, whether an
|
||||
// update is waiting and who is watching; the container knows what that is costing in CPU, memory
|
||||
|
||||
@@ -243,12 +243,20 @@ $_vv_doc_vars = array_merge(vv_conf_vars(), ['SCRIPTS_DIR' => SCRIPTS_DIR]);
|
||||
<div id="vv-transcode-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
<!-- Scope control lives in the header rather than in the body because the body is replaced on
|
||||
every poll, and a control that is re-rendered underneath a click is a control that loses
|
||||
one. Hidden until a partner is known — on a single-host install the two buttons would be
|
||||
two names for the same view, which is how the Media Stack tab handles it too. -->
|
||||
<div class="vv-card" id="vv-streams" style="grid-column:span 4;">
|
||||
<h3>
|
||||
<span style="display:flex;align-items:center;gap:5px;">
|
||||
<span class="vv-ico"><svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><polygon points="1.5,1 1.5,11 10,6"/></svg></span>
|
||||
Streams
|
||||
</span>
|
||||
<span id="vv-streams-scope" style="display:none;gap:4px;margin-left:auto;">
|
||||
<button class="vv-strm-tab on" data-strmscope="local">This host</button>
|
||||
<button class="vv-strm-tab" data-strmscope="mesh">Mesh</button>
|
||||
</span>
|
||||
</h3>
|
||||
<div id="vv-streams-body">Loading...</div>
|
||||
</div>
|
||||
@@ -986,6 +994,12 @@ function vvPollMonitor(live) {
|
||||
const pt = d.partner ?? {};
|
||||
const ptHosts = pt.hosts ?? [];
|
||||
const ptRemote = d.remote_hosts ?? {};
|
||||
|
||||
// The Streams scope control is offered only once there is a partner to combine with. On a
|
||||
// single-host install "This host" and "Mesh" are two names for the same view, and the
|
||||
// Media Stack tab hides its equivalent for the same reason.
|
||||
const _strmScopeEl = document.getElementById('vv-streams-scope');
|
||||
if (_strmScopeEl) _strmScopeEl.style.display = Object.keys(ptRemote).length ? 'flex' : 'none';
|
||||
const ptStatus = pt.enabled
|
||||
? `<span style="color:#4caf50;">enabled</span> · sync every ${pt.sync_min}min`
|
||||
: `<span style="color:#555;">disabled</span>`;
|
||||
@@ -2407,7 +2421,8 @@ function vvRenderStreams() {
|
||||
|
||||
if (sessions.length === 0) {
|
||||
el.innerHTML = `<div class="vv-stream-servers"><div class="vv-stream-left">${badges}</div></div>`
|
||||
+ '<p class="vv-stream-empty">Nothing playing</p>';
|
||||
+ `<p class="vv-stream-empty">Nothing playing${vvStreamScope === 'mesh' ? ' anywhere in the mesh' : ''}</p>`
|
||||
+ vvStreamNodeNotes();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2437,7 +2452,12 @@ function vvRenderStreams() {
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:2px;">
|
||||
<span style="color:${s.paused ? '#fdd835' : '#aaa'};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">
|
||||
<span style="color:${iconColor};">${icon}</span> ${vvEscHtml(s.title)}</span>
|
||||
<span style="color:#444;font-size:10px;margin-left:6px;flex-shrink:0;">${vvEscHtml(s.server)}</span>
|
||||
<span style="color:#444;font-size:10px;margin-left:6px;flex-shrink:0;">${vvEscHtml(s.server)}${
|
||||
// Only in mesh scope: in local scope every row is this host, and the badge would be the
|
||||
// same word on every line.
|
||||
vvStreamScope === 'mesh' && s._hostName
|
||||
? `<span class="vv-strm-node">${vvEscHtml(s._hostName)}</span>` : ''
|
||||
}</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:10px;color:#555;margin-bottom:3px;">
|
||||
<span>${vvEscHtml(s.user)}</span>
|
||||
@@ -2468,22 +2488,36 @@ function vvRenderStreams() {
|
||||
<div class="vv-stream-left">${badges}${mtypeSection}</div>
|
||||
<div class="vv-stream-right">${deviceSection}${resSection}${codecSection}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;">${sCols.map(vvStreamCol).join('')}</div>${overflow}`;
|
||||
<div style="display:flex;gap:10px;">${sCols.map(vvStreamCol).join('')}</div>${overflow}${vvStreamNodeNotes()}`;
|
||||
}
|
||||
|
||||
// Local by default, and it stays the cheap path — one call per media server on this box. Mesh adds
|
||||
// a bounded SSH hop per partner, paid only while the operator is looking at it.
|
||||
let vvStreamScope = 'local';
|
||||
let vvStreamNodes = [];
|
||||
|
||||
function vvPollStreams() {
|
||||
return fetch('/plugins/varaverk/api/media.php')
|
||||
const scope = vvStreamScope;
|
||||
return fetch('/plugins/varaverk/api/media.php' + (scope === 'mesh' ? '?scope=mesh' : ''))
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
// A reply that arrives after the operator switched scope describes the other view. Dropped
|
||||
// rather than rendered: mesh rows landing in a local view is the kind of wrongness nobody
|
||||
// reads as a bug, they just believe it.
|
||||
if (scope !== vvStreamScope) return;
|
||||
|
||||
vvLastSessions = d.sessions ?? [];
|
||||
vvLastStreamNames = d.server_names ?? [];
|
||||
vvStreamServerCount = d.server_count ?? 0;
|
||||
vvStreamNodes = d.nodes ?? [];
|
||||
vvLastStreamPollAt = Math.floor(Date.now() / 1000);
|
||||
|
||||
const el = document.getElementById('vv-streams-body');
|
||||
if (vvStreamServerCount === 0) {
|
||||
el.innerHTML = '<p class="vv-stream-empty">No media servers detected.<br>'
|
||||
+ '<span>Add EMBY_API_KEY / JELLYFIN_API_KEY / PLEX_TOKEN to master.conf to configure.</span></p>';
|
||||
el.innerHTML = scope === 'mesh'
|
||||
? '<p class="vv-stream-empty">No media servers anywhere in the mesh.</p>' + vvStreamNodeNotes()
|
||||
: '<p class="vv-stream-empty">No media servers detected.<br>'
|
||||
+ '<span>Add EMBY_API_KEY / JELLYFIN_API_KEY / PLEX_TOKEN to master.conf to configure.</span></p>';
|
||||
return;
|
||||
}
|
||||
vvRenderStreams();
|
||||
@@ -2491,6 +2525,34 @@ function vvPollStreams() {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// An unreachable partner is stated, never folded into the totals as zero. "Nobody is watching
|
||||
// there" and "that node did not answer" are opposite facts and look identical in a count.
|
||||
function vvStreamNodeNotes() {
|
||||
if (vvStreamScope !== 'mesh') return '';
|
||||
const bad = vvStreamNodes.filter(n => !n.local && !n.reachable);
|
||||
if (!bad.length) return '';
|
||||
return bad.map(n =>
|
||||
`<div style="font-size:10px;color:#a05a2c;margin-top:4px;">${vvEscHtml(n.name)} — unreachable, not counted</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Delegated off the card, so it survives the body being replaced on every poll.
|
||||
document.getElementById('vv-streams')?.addEventListener('click', ev => {
|
||||
const btn = ev.target.closest('[data-strmscope]');
|
||||
if (!btn) return;
|
||||
const next = btn.dataset.strmscope;
|
||||
if (next === vvStreamScope) return;
|
||||
|
||||
vvStreamScope = next;
|
||||
document.querySelectorAll('#vv-streams [data-strmscope]').forEach(b =>
|
||||
b.classList.toggle('on', b.dataset.strmscope === next));
|
||||
|
||||
// Switching is a request to see the other view now, not at the next tick.
|
||||
const el = document.getElementById('vv-streams-body');
|
||||
if (el) el.innerHTML = '<p class="vv-stream-empty">Loading…</p>';
|
||||
vvPollStreams();
|
||||
});
|
||||
|
||||
// Guarded like the others — this one reaches out to every configured media server, so a wedged
|
||||
// Emby is exactly the case where unguarded ticks would stack.
|
||||
vvPollRunner(vvPollStreams, 12000);
|
||||
|
||||
@@ -921,26 +921,48 @@ resolve_remote_ip() {
|
||||
info "$ICON_NET Remote IP: $REMOTE_SERVER"
|
||||
}
|
||||
|
||||
# Resolve any hostname to a Tailscale IPv4 — tries direct lookup, falls back to status parse.
|
||||
# Resolve any hostname to a Tailscale IPv4 — status parse first, DNS as a bounded last resort.
|
||||
# Usage: ip=$(resolve_tailscale_ip "hostname") — returns empty string on failure.
|
||||
#
|
||||
# `tailscale ip -4` used to be tried first, because Tailscale owns the mapping. It also resolves
|
||||
# through MagicDNS, which does not work on this mesh — the two hosts are on separate tailnets
|
||||
# shared into each other — so the name misses and the call blocks on a system DNS lookup until it
|
||||
# times out. Five seconds, every call, on every rsync, partnership check and remote collector.
|
||||
# The status parse costs ten milliseconds and carries the same mapping.
|
||||
#
|
||||
# Exactness is not traded away for the speed: an exact name match against `tailscale status` is as
|
||||
# precise as the lookup it replaces. The prefix match keeps its ambiguity guard, and the DNS path
|
||||
# still runs — bounded, and only when status cannot decide.
|
||||
#
|
||||
# Mirror of vv_resolve_tailscale_ip() in Plugin/unraid/include/config.php; the two must agree.
|
||||
resolve_tailscale_ip() {
|
||||
local hostname="${1,,}"
|
||||
local ip
|
||||
ip=$(tailscale ip -4 "$hostname" 2>/dev/null)
|
||||
if [[ -n "$ip" ]]; then
|
||||
echo "$ip"
|
||||
local status_out exact matches count ip
|
||||
|
||||
status_out=$(tailscale status 2>/dev/null)
|
||||
|
||||
exact=$(echo "$status_out" | awk -v name="$hostname" '
|
||||
{ split(tolower($2), parts, "."); if (parts[1] == name) { print $1; exit } }')
|
||||
if [[ -n "$exact" ]]; then
|
||||
echo "$exact"
|
||||
return
|
||||
fi
|
||||
# Fallback: unambiguous prefix match against tailscale status (either direction) — handles
|
||||
# Unraid's 15-char NetBIOS hostname truncation vs. a longer name recorded in master.conf.
|
||||
# Only accept the match when exactly one peer could qualify; never guess between multiple
|
||||
# candidates that happen to share a prefix (e.g. server1/server10).
|
||||
local matches count
|
||||
matches=$(tailscale status 2>/dev/null | awk -v name="$hostname" '
|
||||
|
||||
# Unambiguous prefix match, either direction — handles Unraid's 15-char NetBIOS hostname
|
||||
# truncation vs. a longer name recorded in master.conf. Only accept the match when exactly one
|
||||
# peer could qualify; never guess between candidates that share a prefix (server1/server10).
|
||||
matches=$(echo "$status_out" | awk -v name="$hostname" '
|
||||
{ split(tolower($2), parts, "."); host = parts[1];
|
||||
if (index(host, name) == 1 || index(name, host) == 1) print $1 }')
|
||||
count=$(echo "$matches" | grep -c .)
|
||||
[[ "$count" -eq 1 ]] && echo "$matches"
|
||||
if [[ "$count" -eq 1 ]]; then
|
||||
echo "$matches"
|
||||
return
|
||||
fi
|
||||
|
||||
# Last resort, and the slow one. Bounded so a broken MagicDNS costs two seconds, not five.
|
||||
ip=$(timeout 2 tailscale ip -4 "$hostname" 2>/dev/null)
|
||||
[[ -n "$ip" ]] && echo "$ip"
|
||||
}
|
||||
|
||||
# Strips the "unraid-" prefix (case-insensitive) and title-cases what remains.
|
||||
|
||||
Reference in New Issue
Block a user