VPN status page


I am using an OpenWRT router as a VPN client, thus I can connect my devices from the routers WiFi securely to the internet. I was wondering, if there is a good way to check if the VPN is up and working.

Something like a simple HTML page would be nice, this is what all devices can easily access.

Any ideas?

You can use any service that shows your public IP address:

These services provide API that you can use for scripting.

1 Like

I certainly wouldn't want to check that page every now and then to check if my VPN was still up.

What I would do instead is, install ssmtp and notify me by email real-time that VPN is down.

Call it in the /etc/hotplug.d/net with a script or a cron schedule to ping a remote IP is not longer present. When VPN is down, ssmpt still works when there is internet access.

So many options, but best would be rebuilding the VPN automatically.

paste this at /www/status.htm..... then make it work :wink:

<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OpenWrt - Interfaces - LuCI</title>
<meta name="viewport" content="initial-scale=1.0">
<link rel="stylesheet" href="/luci-static/bootstrap/cascade.css?v=git-19.020.41695-6f6641d">
<link rel="stylesheet" media="only screen and (max-device-width: 854px)" href="/luci-static/bootstrap/mobile.css?v=git-19.020.41695-6f6641d" type="text/css" />
<link rel="shortcut icon" href="/luci-static/bootstrap/favicon.ico">
<script src="/luci-static/resources/cbi.js?v=git-19.020.41695-6f6641d"></script>
<script src="/luci-static/resources/xhr.js?v=git-19.020.41695-6f6641d"></script>
<span id="xhr_poll_status" style="display:none" onclick="XHR.running() ? XHR.halt() : XHR.run()">
 <span class="label success" id="xhr_poll_status_on">Auto Refresh on</span>
 <span class="label" id="xhr_poll_status_off" style="display:none">Auto Refresh off</span>

<div class="cbi-map" id="cbi-network"><h2 name="content">Interfaces</h2>
<div class="cbi-section-node"><div class="table"><div class="tr cbi-rowstyle-2">

<div class="td col-3 center middle"><div class="ifacebox">
<div class="ifacebox-head" style="background-color:#90f090" title="Part of zone &#34;lan&#34;">
<div class="ifacebox-body" id="lan-ifc-devices" data-network="lan">
<img src="/luci-static/resources/icons/ethernet_disabled.png" style="width:16px; height:16px" /><br /><small>?</small></div></div></div>

<div class="td col-5 left middle" id="lan-ifc-description"><em>Collecting data...</em>
<div class="tr cbi-rowstyle-1"><div class="td col-3 center middle"><div class="ifacebox">
<div class="ifacebox-head" style="background-color:#bd90f9" title="Part of zone &#34;vpnfirewall&#34;">

<strong>NORDVPNTUN</strong></div><div class="ifacebox-body" id="nordvpntun-ifc-devices" data-network="nordvpntun"><img src="/luci-static/resources/icons/ethernet_disabled.png" style="width:16px; height:16px" /><br /><small>?</small>

<div class="td col-5 left middle" id="nordvpntun-ifc-description"><em>Collecting data...</em>
</div></div><div class="tr cbi-rowstyle-2"><div class="td col-3 center middle">

<div class="ifacebox">
<div class="ifacebox-head" style="background-color:#f09090" title="Part of zone &#34;wan&#34;">

<strong>WAN</strong></div><div class="ifacebox-body" id="wan-ifc-devices" data-network="wan">
<img src="/luci-static/resources/icons/ethernet_disabled.png" style="width:16px; height:16px" /><br /><small>?</small></div></div></div>

<div class="td col-5 left middle" id="wan-ifc-description"><em>Collecting data...</em></div></div>


<script type="text/javascript">//<![CDATA[
	function iface_reconnect(id) {

		var d = document.getElementById(id + '-ifc-description');
		if (d) d.innerHTML = '<em>Interface is reconnecting...</em>';

		(new XHR()).post('/cgi-bin/luci/admin/network/iface_reconnect/' + id,
			{ token: 'c48dd69319e896f5093d6149f013621c' }, XHR.run);

	function iface_delete(ev) {
		if (!confirm("Really delete this interface? The deletion cannot be undone! You might lose access to this device if you are connected via this interface")) {
			return false;

		ev.target.previousElementSibling.value = '1';
		return true;

	var networks = [];

	document.querySelectorAll('[data-network]').forEach(function(n) {

	function render_iface(ifc) {
		return E('span', { class: 'cbi-tooltip-container' }, [
			E('img', { 'class' : 'middle', 'src': '/luci-static/resources/icons/%s%s.png'.format(
				ifc.is_alias ? 'alias' : ifc.type,
				ifc.is_up ? '' : '_disabled') }),
			E('span', { 'class': 'cbi-tooltip ifacebadge large' }, [
				E('img', { 'src': '/luci-static/resources/icons/%s%s.png'.format(
					ifc.type, ifc.is_up ? '' : '_disabled') }),
				E('span', { 'class': 'left' }, [
					E('strong', 'Type: '), ifc.typename, E('br'),
					E('strong', 'Device: '), ifc.ifname, E('br'),
					E('strong', 'Connected: '), ifc.is_up ? 'yes' : 'no', E('br'),
					ifc.macaddr ? E('strong', 'MAC: ') : '',
					ifc.macaddr ? ifc.macaddr : '',
					ifc.macaddr ? E('br') : '',
					E('strong', 'RX: '), '%.2mB (%d Pkts.)'.format(ifc.rx_bytes, ifc.rx_packets), E('br'),
					E('strong', 'TX: '), '%.2mB (%d Pkts.)'.format(ifc.tx_bytes, ifc.tx_packets)

	XHR.poll(5, '/cgi-bin/luci/admin/network/iface_status/' + networks.join(','), null,
		function(x, ifcs)
			if (ifcs)
				for (var idx = 0; idx < ifcs.length; idx++)
					var ifc = ifcs[idx];
					var html = '';

					var s = document.getElementById(ifc.id + '-ifc-devices');
					if (s)
						while (s.firstChild)


						if (ifc.subdevices && ifc.subdevices.length)
							var sifs = [ ' (' ];

							for (var j = 0; j < ifc.subdevices.length; j++)


							s.appendChild(E('span', {}, sifs));

						s.appendChild(E('small', {}, ifc.is_alias ? 'Alias of &#34;%s&#34;'.format(ifc.is_alias) : ifc.name));

					var d = document.getElementById(ifc.id + '-ifc-description');
					if (d && ifc.proto && ifc.ifname)
						var desc = null;

						if (ifc.is_dynamic)
							desc = 'Virtual dynamic interface';
						else if (ifc.is_alias)
							desc = 'Alias Interface';

						if (ifc.desc)
							desc = desc ? '%s (%s)'.format(desc, ifc.desc) : ifc.desc;

						html += String.format('<strong>Protocol:</strong> %h<br />', desc || '?');

						if (ifc.is_up)
							html += String.format('<strong>Uptime:</strong> %t<br />', ifc.uptime);

						if (!ifc.is_dynamic && !ifc.is_alias)
							if (ifc.macaddr)
								html += String.format('<strong>MAC:</strong> %s<br />', ifc.macaddr);

							html += String.format(
								'<strong>RX:</strong> %.2mB (%d Pkts.)<br />' +
								'<strong>TX:</strong> %.2mB (%d Pkts.)<br />',
									ifc.rx_bytes, ifc.rx_packets,
									ifc.tx_bytes, ifc.tx_packets

						if (ifc.ipaddrs && ifc.ipaddrs.length)
							for (var i = 0; i < ifc.ipaddrs.length; i++)
								html += String.format(
									'<strong>IPv4:</strong> %s<br />',

						if (ifc.ip6addrs && ifc.ip6addrs.length)
							for (var i = 0; i < ifc.ip6addrs.length; i++)
								html += String.format(
									'<strong>IPv6:</strong> %s<br />',

						if (ifc.ip6prefix)
							html += String.format('<strong>IPv6-PD:</strong> %s<br />', ifc.ip6prefix);

						if (ifc.errors)
							for (var i = 0; i < ifc.errors.length; i++)
								html += String.format(
									'<em class="error"><strong>Error:</strong> %h</em><br />',

						d.innerHTML = html;
					else if (d && !ifc.proto)
						var e = document.getElementById(ifc.id + '-ifc-edit');
						if (e)
							e.disabled = true;

						d.innerHTML = String.format(
							'<em>Unsupported protocol type.</em><br />' +
							'<a href="%h">Install protocol extensions...</a>',
					else if (d && !ifc.ifname)
						d.innerHTML = String.format(
							'<em>Network without interfaces.</em><br />' +
							'<a href="/cgi-bin/luci/admin/network/network/%s?tab.network.%s=physical">Assign interfaces...</a>',
								ifc.name, ifc.name
					else if (d)
						d.innerHTML = '<em>Interface not present or not connected yet.</em>';
<!-- /nsection -->

<script type="text/javascript">cbi_init();</script>

<a href="https://github.com/openwrt/luci">Powered by LuCI openwrt-18.06 branch (git-19.020.41695-6f6641d)</a> / OpenWrt 18.06.2 r7676-cddd7b4c77

Seriously tho... i'd do a php version of the above and iptables DNAT it when VPN is down et. al.

If you're using Wireguard VPN and the luci-app-wireguard package is installed, just browse to:

Another approach is to setup the firewall such that it only allows traffic through the VPN tunnel and not the WAN in general. This way, if it goes down, internet breaks. You'll know immediately without having to browse to a website or to setup scripts and such.

The one caveat is that if the internet connection goes down in general, you won't immediately know if the VPN dropped or your ISP connection -- but in this case, you can connect to the router (LuCI or ssh) to see what is happening.

1 Like

@anon50098793: Did you paste the whole HTML document? Seems like there is a part missing.

I like the idea of a file in /www/ : this means you don't have to login into Luci, this saves you a bit of time and typing.

@psherman: Yes I also have this firewall setup. But as you say, if the connection goes down, you don't know where the problem is.

No, just had a 2 second play around.... But I think it's definitely a feature that would be cool...

cp /usr/lib/lua/luci/view/sysauth.htm /usr/lib/lua/luci/view/sysauth.htm.org
cat status.htm >> /usr/lib/lua/luci/view/sysauth.htm

HACK2.... basic recipe for graph in header...

1. Install luci app statistics
2. Go to setup > interfaces > monitor all but lo
3. Wait 30 secs then go and see the graphs
5. ln -s /tmp/rrdimg/`hostname`/interface-tun0/1.3600.png tun0.1.png

6. cp /usr/lib/lua/luci/view/themes/bootstrap/header.htm /usr/lib/lua/luci/view/themes/bootstrap/header.htm.org
7. vi /usr/lib/lua/luci/view/themes/bootstrap/header.htm
8. At line 177ish after div container>
9. <img src="/tun0.1.png" height="50px"></img>

Needs refresh fix.....

Actually an idea would be, to make a traceroute with a max ttl of, lets say 5. So you can see if it passes through the hosts from the VPN gateway.

traceroute to openwrt.org (, 5 hops max, 60 byte packets
 1  OpenWrt.lan (  3.938 ms 
 2 (  225.421 ms 
 3 (  225.339 ms
 4  server.myprovider.net (  225.243 ms 
 5  gateway.somewhere.net ( 233.214 ms

I tried to make a small HTML page directly in /www, but I cannot make the requests for the traceroute. Is it possible to make a request to


without being logged in to Luci?

I think a guru will help us out as to the options when it comes to running a script / custom page when it comes to auth hooks... and official means......

One option is to

-Run a script as a service
-output to "/www/page.htm" ( which is link to /tmp to prevent writes )
-add a static "embed" tag in a page to either link to or import the content of that file

This method has the benefit of keeping the system secure.... and isolating the "nuts and bolts" for the UI.....

A basic example..... unpolished as usual :wink:


while sleep 123; do
        t=$(ping -c $n | grep -o -E '\d+ packets r' | grep -o -E '\d+')
        if [ "$t" -eq 0 ]; then
			echo "DOWN" > /www/page2.htm
                        #/etc/init.d/openvpn restart
			echo "UP" > /www/page2.htm


#!/bin/sh /etc/rc.common
export START=94
export USE_PROCD=1

start_service() {
	/bin/vpnwatch.sh &

stop_service() {
	echo "needs fixing"

chmod +x /bin/vpnwatch.sh
/etc/init.d/vpnwatch enable
/etc/init.d/vpnwatch start

Then as per step 8 in my last post... something like this is one way to include the contents;

local fs = require "nixio.fs"
local e = fs.readfile("/www/page2.htm")

( needs some basic checking and error handling etc... )

Apologies to all the proper coders on this site.... :wink:

A service is IMHO a bit too much because it will be executed regularly. But I just want to check the status from time to time, and if I check it, it should be very recent, not 1 minute ago or something.

On the other hand, I like your idea for a standalone solution outside of the Luci. I think the right place for this would be in /www/cgi-bin

I will try that one and explain about it later.

An extremely simple solution would be using the OpenVPN --status log option, then create a LuCI status page template include to render it.

The default OpenVPN init script should already pass --status to the started instances (verify with ps ww | grep openvpn). - the path should be similar to /var/run/openvpn.example.status.

To display that on the main status page, paste the following contents into a file called /usr/lib/lua/luci/view/admin_status/index/openvpn.htm:

	local wa = require "luci.tools.webadmin"
	local fs = require "nixio.fs"
	local file, line
	local tunnels = {}

	for file in fs.glob("/var/run/openvpn.*.status") do
		local ok, lines = pcall(io.lines, file)

		if ok then
			local tunnel = {
				name = file:match("^/var/run/openvpn%.(.+)%.status$")

			for line in lines do
				local key, val = line:match("^([^,]+),(.+)$")
				if key and val then
					tunnel[key] = val

			tunnels[#tunnels+1] = tunnel

<div class="cbi-section">
	<h3><%:Active OpenVPN connections%></h3>
	<div class="table">
		<div class="tr table-titles">
			<div class="th"><%:Name%></div>
			<div class="th"><%:Last Update%></div>
			<div class="th"><%:Connection RX%></div>
			<div class="th"><%:Connection TX%></div>
			<div class="th"><%:Tunnel RX%></div>
			<div class="th"><%:Tunnel TX%></div>
		<% if #tunnels == 0 then %>
		<div class="tr placeholder">
			<div class="td"><em><%:There is no OpenVPN connected.%></em></div>
		<% else %>
			<% for _, tunnel in ipairs(tunnels) do %>
				<div class="tr">
					<div class="td"><%=tunnel["name"]%></div>
					<div class="td"><%=tunnel["Updated"]%></div>
					<div class="td"><%=wa.byte_format(tonumber(tunnel["TCP/UDP read bytes"] or 0))%></div>
					<div class="td"><%=wa.byte_format(tonumber(tunnel["TCP/UDP write bytes"] or 0))%></div>
					<div class="td"><%=wa.byte_format(tonumber(tunnel["TUN/TAP read bytes"] or 0))%></div>
					<div class="td"><%=wa.byte_format(tonumber(tunnel["TUN/TAP write bytes"] or 0))%></div>
			<% end %>
		<% end %>

Thank you all for the helpful replays. I finally did this kind of status page:

It shows if the VPN server and the next router are reachable.

It consists of two files, one is a cgi script to execute the ping and the traceroute. The other one is a HTML file with a bit of Javascript to asynchronously do the ping.

You can have a look at: https://github.com/camarel/vpn-status


mmmmmmmmm asynchronousss!!! :beers:

nice job.

Hello again, so for my status page I created a HTML page and a CGI script. I am passing the hostname to ping / traceroute as a URL parameter to the cgi script. Is this kind of parameter sanitation ok / sufficient?



# sanitize the input

Example url:

1 Like

Basic number selectable menu ... php based.... insecure proof of concept.

I like the way you lookup the vpn gateway, I do it the same way now in my script :wink:

If we're already shell bikeshedding here then I'd suggest to use ip route get (or any other well known public IP) to figure out the effective outgoing default gw, regardless of the OpenVPN def1 option. :slight_smile:

1 Like

mmh, I am not so good with numbers I rather prefer a well known domain:

ip=$(nslookup twitter.com | grep "Address\ 1" | sed 's/Address\ 1://')
route=$(ip route get $ip)

gw=$(ip route show 0/1 | cut -d' ' -f3)

if [ -z "${route##*$gw*}" ]; then
   echo "tunneled!"


1 Like