vpn/wireguard: Change tracking of wg peer status, improve widget and diagnostics (#8337)

* vpn/wireguard: Introduce latest-handshake-age to calculate if tunnel is online in backend. Implement it in wireguard.js widget and diagnostics.volt

* vpn/wireguard: expose peer-connected via API to approximate state of wireguard peers online/offline status, change status formatter to show statos of interfaces and peers, improve diagnostic grid

* vpn/wireguard: Move epoch calculation from frontend to controller

* vpn/wireguard: Track 3 different status instead of a boolean offline/online. Online means a handshake happened recently, Stale means a handshake happened in the past above a threshold of 300s, Offline means there was never a handshake yet. The same icons are implemented in the widget and the wireguard diagnostics page.

* vpn/wireguard: Remote peer disconnected translation since this is tracked by the icon now. Add stale translation.

* vpn/wireguard: Compact widget information for better readability
This commit is contained in:
Monviech 2025-02-19 08:58:54 +01:00 committed by GitHub
parent 82b36deee3
commit 2cc36105da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 116 additions and 37 deletions

View File

@ -95,6 +95,26 @@ class ServiceController extends ApiMutableServiceControllerBase
$record['name'] = $key_descriptions[$key];
}
}
if (!empty($record['latest-handshake'])) {
$record['latest-handshake-age'] = time() - (int)$record['latest-handshake'];
$record['latest-handshake-epoch'] = date('Y-m-d H:i:s', (int)$record['latest-handshake']);
} else {
$record['latest-handshake-age'] = null;
$record['latest-handshake-epoch'] = null;
}
// Peer is considered online if handshake was within 300s, wg handshakes approx every 120s.
if ($record['type'] === 'peer' && !is_null($record['latest-handshake-age'])) {
if ($record['latest-handshake-age'] <= 300) {
$record['peer-status'] = 'online';
} elseif ($record['latest-handshake-age'] > 300) {
$record['peer-status'] = 'stale';
}
} else {
$record['peer-status'] = 'offline';
}
$record['ifname'] = $ifnames[$record['if']];
}
$filter_funct = null;

View File

@ -41,13 +41,36 @@
return row[column.id];
},
epoch: function(column, row) {
if (row[column.id]) {
return moment.unix(row[column.id]).local().format('YYYY-MM-DD HH:mm:ss');
if (row[column.id] !== null) {
return row[column.id]
} else {
return '';
}
},
seconds: function(column, row) {
if (row[column.id] !== null) {
return row[column.id] + "s";
} else {
return '';
}
},
status: function(column, row) {
if (row.type === 'peer' && row['peer-status'] === 'stale') {
return '<span class="fa fa-question-circle fa-fw" data-toggle="tooltip" title="{{ lang._('Stale') }}"></span>';
}
if (
(row.type === 'interface' && row.status === 'up') ||
(row.type === 'peer' && row['peer-status'] === 'online')
) {
return '<span class="fa fa-check-circle fa-fw text-success" data-toggle="tooltip" title="{{ lang._('Online') }}"></span>';
}
return '<span class="fa fa-times-circle fa-fw text-danger" data-toggle="tooltip" title="{{ lang._('Offline') }}"></span>';
},
}
},
requestHandler: function(request){
if ( $('#type_filter').val().length > 0) {
@ -62,6 +85,10 @@
$('#grid-sessions').bootgrid('reload');
});
$("#grid-sessions").on('loaded.rs.jquery.bootgrid', function() {
$('[data-toggle="tooltip"]').tooltip();
});
$("#type_filter_container").detach().prependTo('#grid-sessions-header > .row > .actionBar > .actions');
});
@ -80,13 +107,14 @@
<table id="grid-sessions" class="table table-condensed table-hover table-striped table-responsive">
<thead>
<tr>
<th data-column-id="if" data-type="string" data-width="8em">{{ lang._('Device') }}</th>
<th data-column-id="type" data-type="string" data-width="8em" data-visible="false">{{ lang._('Type') }}</th>
<th data-column-id="status" data-type="string" data-width="8em" >{{ lang._('Status') }}</th>
<th data-column-id="public-key" data-type="string" data-identifier="true">{{ lang._('Public key') }}</th>
<th data-column-id="status" data-formatter="status" data-type="string" data-width="6em" >{{ lang._('Status') }}</th>
<th data-column-id="if" data-type="string" data-width="6em">{{ lang._('Device') }}</th>
<th data-column-id="type" data-type="string" data-width="6em">{{ lang._('Type') }}</th>
<th data-column-id="public-key" data-type="string" data-width="26em" data-identifier="true" data-visible="false">{{ lang._('Public key') }}</th>
<th data-column-id="name" data-type="string">{{ lang._('Name') }}</th>
<th data-column-id="endpoint" data-type="string">{{ lang._('Port / Endpoint') }}</th>
<th data-column-id="latest-handshake" data-formatter="epoch" data-type="numeric">{{ lang._('Handshake') }}</th>
<th data-column-id="latest-handshake-epoch" data-formatter="epoch" data-type="numeric" data-visible="false">{{ lang._('Handshake') }}</th>
<th data-column-id="latest-handshake-age" data-formatter="seconds" data-type="numeric">{{ lang._('Handshake Age') }}</th>
<th data-column-id="transfer-tx" data-formatter="bytes" data-type="numeric">{{ lang._('Sent') }}</th>
<th data-column-id="transfer-rx" data-formatter="bytes" data-type="numeric">{{ lang._('Received') }}</th>
</tr>

View File

@ -288,9 +288,8 @@
<notunnels>No tunnels connected</notunnels>
<online>Online</online>
<offline>Offline</offline>
<total>Tunnels</total>
<stale>Stale</stale>
<notavailable>N/A</notavailable>
<disconnected>Peer disconnected</disconnected>
</translations>
</Wireguard>
<services>

View File

@ -69,7 +69,7 @@ export default class Wireguard extends BaseTableWidget {
}
displayError(message) {
$('#wgTunnelTable'). empty().append(
$('#wgTunnelTable').empty().append(
$(`<div class="error-message"><a href="/ui/wireguard/general">${message}</a></div>`)
);
}
@ -77,29 +77,61 @@ export default class Wireguard extends BaseTableWidget {
processTunnels(newTunnels) {
$('.wireguard-interface').tooltip('hide');
let now = moment().unix(); // Current time in seconds
let tunnels = newTunnels.filter(row => row.type == 'peer').map(row => ({
ifname: row.ifname ? row.if + ' (' + row.ifname + ') ' : row.if,
name: row.name,
allowed_ips: row['allowed-ips'] || this.translations.notavailable,
rx: row['transfer-rx'] ? this._formatBytes(row['transfer-rx']) : this.translations.notavailable,
tx: row['transfer-tx'] ? this._formatBytes(row['transfer-tx']) : this.translations.notavailable,
latest_handshake: row['latest-handshake'], // No fallback since we handle if 0
latest_handshake_fmt: row['latest-handshake'] ? moment.unix(row['latest-handshake']).local().format('YYYY-MM-DD HH:mm:ss') : null,
connected: row['latest-handshake'] && (now - row['latest-handshake']) <= 180, // Considered online if last handshake was within 3 minutes
statusIcon: row['latest-handshake'] && (now - row['latest-handshake']) <= 180 ? 'fa-exchange text-success' : 'fa-exchange text-danger',
publicKey: row['public-key'],
uniqueId: row.if + row['public-key']
}));
let tunnels = newTunnels
.filter(row => row.type == 'peer')
.map(row => ({
if: row.if,
tunnels.sort((a, b) => a.connected === b.connected ? 0 : a.connected ? -1 : 1);
name: row.name,
allowed_ips: row['allowed-ips'] || this.translations.notavailable,
let onlineCount = tunnels.filter(tunnel => tunnel.connected).length;
let offlineCount = tunnels.length - onlineCount;
rx: row['transfer-rx']
? this._formatBytes(row['transfer-rx'])
: this.translations.notavailable,
tx: row['transfer-tx']
? this._formatBytes(row['transfer-tx'])
: this.translations.notavailable,
// No fallback since we handle if null
latest_handshake_epoch: row['latest-handshake-epoch'],
peerStatus: row['peer-status'],
statusIcon: row['peer-status'] === 'online'
? 'fa-check-circle fa-fw text-success'
: row['peer-status'] === 'stale'
? 'fa-question-circle fa-fw'
: 'fa-times-circle fa-fw text-danger',
statusTooltip: row['peer-status'] === 'online'
? this.translations.online
: row['peer-status'] === 'stale'
? this.translations.stale
: this.translations.offline,
publicKey: row['public-key'],
uniqueId: row.if + row['public-key']
}));
tunnels.sort((a, b) => {
if (a.peerStatus === b.peerStatus) return 0;
if (a.peerStatus === 'online') return -1;
if (a.peerStatus === 'stale' && b.peerStatus !== 'online') return -1;
return 1;
});
let onlineCount = tunnels.filter(tunnel => tunnel.peerStatus === 'online').length;
let staleCount = tunnels.filter(tunnel => tunnel.peerStatus === 'stale').length;
let offlineCount = tunnels.length - onlineCount - staleCount;
let summaryRow = `
<div>
<span>${this.translations.total}: ${tunnels.length} | ${this.translations.online}: ${onlineCount} | ${this.translations.offline}: ${offlineCount}</span>
<span>
${this.translations.online}: ${onlineCount} |
${this.translations.stale}: ${staleCount} |
${this.translations.offline}: ${offlineCount}
</span>
</div>`;
super.updateTable('wgTunnelTable', [[summaryRow, '']], 'wg-summary');
@ -110,23 +142,23 @@ export default class Wireguard extends BaseTableWidget {
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center;">
<i class="fa ${tunnel.statusIcon} wireguard-interface" style="cursor: pointer;"
data-toggle="tooltip" title="${tunnel.connected ? this.translations.online : this.translations.offline}">
data-toggle="tooltip" title="${tunnel.statusTooltip}">
</i>
&nbsp;
<span><b>${tunnel.ifname}</b></span>
<a href="/ui/wireguard/general#peers&search=${encodeURIComponent(tunnel.name)}" target="_blank" rel="noopener noreferrer">
${tunnel.if} | ${tunnel.name}
</a>
</div>
</div>`;
let row = `
<div>
<span>
<a href="/ui/wireguard/general#peers&search=${encodeURIComponent(tunnel.name)}" target="_blank" rel="noopener noreferrer">
${tunnel.name}
</a> | ${tunnel.allowed_ips}
${tunnel.allowed_ips}
</span>
</div>
<div>
${tunnel.latest_handshake_fmt
? `<span>${tunnel.latest_handshake_fmt}</span>
${tunnel.latest_handshake_epoch
? `<span>${tunnel.latest_handshake_epoch}</span>
<div style="padding-bottom: 10px;">
<i class="fa fa-arrow-down" style="font-size: 13px;"></i>
${tunnel.rx}
@ -134,7 +166,7 @@ export default class Wireguard extends BaseTableWidget {
<i class="fa fa-arrow-up" style="font-size: 13px;"></i>
${tunnel.tx}
</div>`
: `<span>${this.translations.disconnected}</span>`}
: ''}
</div>`;
// Update the HTML table with the sorted rows