Skip to content

zorael/wg_monitor

Repository files navigation

wg_monitor

Monitors other peers in a WireGuard VPN and sends a notification if contact with a peer is lost.

The main purpose of this is to monitor Internet-connected locations for power outages, using WireGuard handshakes as a way for sites to phone home. Each site needs an always-on, always-online computer to act as a WireGuard peer, for which something like a Raspberry Pi Zero 2W is cheap and more than sufficient. (May require cross-compilation.)

In a hub-and-spoke WireGuard configuration, this program should be run on the hub server, with an additional instance on at least one other geographically disconnected peer to monitor the hub. In other configurations, it can be run on any peer with visibility of other peers, but a secondary instance monitoring the first is recommended in any setup. If the hub loses power, it cannot report itself as being lost.

Peers must have a PersistentKeepalive setting in their WireGuard configuration with a value comfortably lower than the peer timeout of this program. This timeout is 10 minutes by default.

Notifications can be sent as Slack messages, as short emails via Batsign, and/or by invocation of an external command (like notify-send, wall or sendmail).

tl;dr

Usage: wg_monitor [OPTIONS]

Options:
  -c, --config-dir <path>   Specify an alternative configuration directory
      --resume              Word the first notification as if the program was not just started
      --skip-first          Skip the first run and thus the first notification
      --disable-timestamps  Disable timestamps in terminal output
      --sleep <duration>    Sleep for a specified duration before starting the monitoring loop
      --show                Output configuration to screen and exit
  -v, --verbose             Print some additional information
  -d, --debug               Print much more additional information
      --dry-run             Perform a dry run, echoing what would be done
      --save                Write configuration to disk
  -V, --version             Display version information and exit

Pre-compiled binaries for x86_64 and aarch64 architectures are available under Releases.

Create a configuration file and a peer list by passing --save.

cargo run -- --save

toc

compilation

This project uses Cargo for compilation and dependency management. Grab it from your repositories, install it via Homebrew, or download it with the official rustup installation script.

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

You may have to add $HOME/.cargo/bin to your $PATH.

Use cargo build to build the project. This stores the resulting binary as target/<profile>/wg_monitor, where <profile> is one of debug or release, depending on what profile is being built. debug is the default; you can make it build in release mode with --release.

cargo build
cargo build --release

To compile the program and run it immediately, use cargo run. If you also want to pass command-line flags to the program, separate them from cargo run with double dashes --.

cargo run -- --help
cargo run -- --save

You can find the binaries you compile with Cargo in the target/<profile>/ subdirectory of the project, where <profile> is either debug or release, depending on what profile you built with.

See the systemd section for instructions on how to set it up as a system daemon that is automatically started on boot.

cross-compilation

A device like the Pi Zero 2W can run the program but does not have enough memory to compile it, at least not with default flags. You can probably still build it on such a Pi by adding swap and exercising a lot of patience, but the convenient way is to just cross-compile it on another computer and transferring the resulting binary.

Regrettably, manually setting up cross-compilation can be non-trivial. As such, use of one of cargo-cross or cargo-zigbuild is recommended (but not required). For the latter you need to install a Zig compiler. Refer to your repositories, alternatively install it via Homebrew (brew install zig).

Note that your $CFLAGS environment variable must not contain -march=native for all dependencies to successfully build.

cargo install cargo-cross
CFLAGS="-O2 -pipe" cargo cross build --target=aarch64-unknown-linux-gnu
cargo install cargo-zigbuild
CFLAGS="-O2 -pipe" cargo zigbuild --target=aarch64-unknown-linux-gnu

This should require upwards of 500 Mb of free system memory, effectively exceeding the total RAM of a Pi Zero 2W.

Both cargo cross build and cargo zigbuild default to compiling with the --profile=release flag, applying some optimizations and considerably lowering the resulting binary file size as compared to when building with --profile=dev.

rsync -avz --progress target/aarch64-unknown-linux-gnu/release/wg_monitor user@pi:~/

Replace release with debug to transfer the binary of a --profile=dev build.

-j1

You may have some luck building it on the Pi if you build it in a serial mode, compiling one dependency at a time. Swap is probably still required.

cargo build -j1

Mind that build times will be very long. Remember to use a heatsink. (Cross-compilation remains recommended.)

configuration

Configuration is done by modifying files created in the configuration directory, which is one of the following locations, in decreasing order of precedence:

  • ...as was explicitly declared with --config-dir=/path/to/directory
  • $WG_MONITOR_CONFIG_DIR if set
  • /etc/wg_monitor if your user is root
  • $XDG_CONFIG_HOME/wg_monitor if $XDG_CONFIG_HOME is set
  • $HOME/.config/wg_monitor
  • fail if $HOME is unset

Running the program with --save will create this directory, including parent directories if necessary.

cargo run -- --save

Mind that the program will likely require to be run with root permissions to be able to issue queries for handshake timestamps of the WireGuard interface. As per the list above, running as root would make the configuration directory default to /etc/wg_monitor.

config.toml

As part of --save, a new config.toml will be created in the configuration directory, if it does not already exist. Edit it like you would any text file. Subsequent invocations with --save will not wipe an existing config.toml, but beware that any comments will be removed.

peers.txt

A new peers.txt file will also have been created in the configuration directory, next to the config.toml file. Complete it with the public keys of the peers you want to monitor. You can make it easier to distinguish between peers by appending a human-readable name after each key, separated by whitespace (such as a space or a tab).

Lines that start with an octothorpe # are ignored.

# <public key> <description>
vfpuUkQqZVkwZx1qvUkqcS+5PzqFqpWVQUO3nK3HXUk= Alice's house
PL5QAuDP8bM62q85P7YW+M5cz2WilbtKN6LDKhLRXCM= Bob's apartment
#jZnRVdClxXzoNMhI/8skpP9IafAFQDb+qqhppSQlTWE= Eve's cottage

backends

There are three available notification backends.

slack

Messages to Slack channels can trivially be pushed by use of webhook URLs. HTTP requests made to these will end up as messages in the channels they refer to. See this guide in the Slack documentation for developers on how to get started.

It is recommended that you make an entry in /etc/hosts to manually resolve hooks.slack.com to an IP of the underlying Slack server, to avoid potential DNS lookup failures.

URLs must be quoted. You may enter any number of URLs as long as you separate the individual strings with a comma.

[slack]
enabled = true
urls = ["https://hooks.slack.com/services/REDACTED/ALSOTHIS/asdfasdfasdf", "https://hooks.slack.com/services/ASDFASDF/FDSAFDSA/qwertyiiioqer"]

formatting messages

Slack supports some formatting. Text between asterisks * will be in *bold*, text between underscores _ will be in _italics_, text between tildes ~ will be in ~strikethrough~, etc.

Strings defined in the configuration file can make use of this.

[slack.strings]
header = ""
first_run_header = ":zap: *Power restored* _(or restart of device)_"
bullet_point = " *-* "

See this help article for the full listing.

batsign

Batsign is a free (gratis) service with which you can send brief emails. Requires registration, after which you will receive a unique URL that should be kept secret. HTTP requests made to this URL will send an email to the address you specified when registering.

It is recommended that you make an entry in /etc/hosts to manually resolve batsign.me to the IP of the underlying Batsign server, to avoid potential DNS lookup failures.

URLs must be quoted. You may enter any number of URLs as long as you separate the individual strings with a comma.

[batsign]
enabled = true
urls = ["https://batsign.me/at/example@address.tld/asdfasdf", "https://batsign.me/at/other@address.tld/fdsafdafa"]

formatting mails

It is not possible to format text in Batsign emails with HTML markup. The best you can do is to use Unicode characters.

external command

You can also have the program execute an external command as a way to push notifications, although there are several caveats.

  • The command run will be passed several arguments in a specific hardcoded order, and it is unlikely that it will immediately suit whatever notification program you want to use. Realistically what you will end up doing is writing some glue-layer script that maps the arguments to something the notification program can use.

  • If you run the project binary as root (which may well be unavoidable) the external command specified will in turn also be run as root. If you need it to be run as a different user, you will have to use systemd-run or su in your shell script.

In the configuration file;

[command]
enabled = true
commands = ["/absolute/path/to/script.sh"]

Remember to chmod the script executable +x.

arguments

The order of arguments is as follows:

  1. The composed message body, formatted with strings as defined in the configuration file
  2. The path to the peers.txt file
  3. The number of times the main loop has run (starting at 0, unless --resume was passed, in which case it starts at 1)
  4. A comma-separated string of lost keys in the format "key1:timestamp1,key2:timestamp2,..."
  5. A comma-separated string of missing keys in the format "key1:timestamp1,key2:timestamp2,..."
  6. In alert notifications, a comma-separated string of keys that are now lost in the format "key1:timestamp1,key2:timestamp2,..."
  7. In alert notifications, a comma-separated string of keys that are now missing in the format "key1:timestamp1,key2:timestamp2,..."
  8. In alert notifications, a comma-separated string of keys that were lost (but are no longer) in the format "key1:timestamp1,key2:timestamp2,..."
  9. In alert notifications, a comma-separated string of keys that were missing (but are no longer) in the format "key1:timestamp1,key2:timestamp2,..."

Any parameter for which there is no value (as in, there are no lost peers), the argument is passed but is simply an empty string "".

example scripts

notify-send can be used to send desktop notifications. Here are some example glue-layer scripts that map the arguments passed by the external command backend into something notify-send can work with.

This will push a desktop notification to all users currently logged into a graphical environment on the current machine.

Adapted from the example on the Arch Linux wiki:

#!/bin/bash

# $1 contains the composed message
# $2 contains the path to the peers.txt file, not relevant for this script
# $3 contains the loop iteration number

title="WireGuard Monitor"
icon="network-wireless-disconnected"
urgency="critical"
loop_number="$3"
message="$1"

ids=( $(loginctl list-sessions -j | jq -r '.[] | .session') )

if [[ $loop_number = 0 ]]; then
    # run 0
    summary="$title: first run"
else
    summary="$title: update"
fi

for id in "${ids[@]}" ; do
    [[ $(loginctl show-session $id --property=Type) =~ (wayland|x11) ]] || continue

    user=$(loginctl show-session $id --property=Name --value)

    systemd-run --machine=${user}@.host --user \
        notify-send \
            --app-name="$title" \
            --icon="$icon" \
            --urgency="$urgency" \
            "$summary" \
            "$message"
done

A similar script but for only one user. Change the user= line to match the user that should receive the notification. It accepts both usernames and user IDs.

#!/bin/bash

# $1 contains the composed message
# $2 contains the path to the peers.txt file, not relevant for this script
# $3 contains the loop iteration number

# make sure to change the "user" variable to the actual username or user ID
# of the user you want to send the notification to, e.g. 1000, "bob" or "alice".

user=1000

title="WireGuard Monitor"
icon="network-wireless-disconnected"
urgency="critical"
loop_number="$3"
message="$1"

if [[ $loop_number = 0 ]]; then
    # run 0
    summary="$title: first run"
else
    summary="$title: update"
fi

systemd-run --machine=${user}@.host --user \
    notify-send \
        --app-name="$title" \
        --icon="$icon" \
        --urgency="$urgency" \
        "$summary" \
        "$message"

tailoring messages

The config.toml file contains strings sections for each backend, in which you can define strings to be used in the formatting of messages.

[slack.alert_strings]
header = 'WireGuard Monitor report\n'
first_run_header = 'WireGuard Monitor starting up\n'
first_run_missing = 'Missing:\n'
lost = 'Lost:\n'
forgot = 'Lost to a network reset:\n'
appeared = 'Just appeared:\n'
returned = 'Returned:\n'
still_lost = 'Still lost:\n'
still_missing = 'Still have yet to see (since last restart):\n'
footer = ""
bullet_point = " - "
peer_with_timestamp = "{peer} (last seen {ago})"
peer_no_timestamp = "{peer}"
returning_peer_with_timestamp = "{peer} (returned {ago})"

noteworthy points

  • You can add extra linebreaks by inserting a newline character \n into the string, as exemplified above.
  • You can prevent the sections that strings represent from being included in the message by setting them to an empty string "".
  • You can omit the entire first-run message by setting first_run_header likewise to an empty string "". (also achieved by passing --skip-first)
  • Peers are indented in peer lists with the bullet_point string, followed immediately by one of the peer_* strings.
  • Messages end with the footer string when defined, which can be used to add a signature or some other kind of closing remark.
  • You can prevent peers from being included in messages (at all) by setting all of peer_with_timestamp, peer_no_timestamp and returning_peer_with_timestamp to an empty string "". This leaves only the headers in the message.
  • If a message is rendered empty, such as if all sections with peers to report were omitted due to their strings being empty "", it will not be sent. The footer is not included in this consideration.
  • The Slack backend supports some Markdown-like formatting. The Batsign one does not, and for the external command one it naturally depends on the command it runs.
  • Even if a backend does not support formatting, it may well support Unicode characters (and emoji), so you can still get creative with those.

Experiment with the strings. Run the program with --dry-run on an artificially low timeout (maybe 20s) to force some notifications to be simulated. See how the strings you defined are used in the composed message as it gets output to the terminal.

[monitor]
interface = "wg0"
check_interval = "20s"
timeout = "40s"
reminder_interval = "45s"
retry_interval = "5s"

placeholders

As part of the strings, you can use certain placeholders which will be replaced with actual values when messages are composed.

placeholder becomes
{peer} the human-readable name of the peer (*_timestamp only)
{key} the WireGuard public key of the peer (*_timestamp only)
{ago} the time since the last handshake with the peer, in a human-readable format (*_with_timestamp only)
{unix} the time since the last handshake with the peer, in seconds since the UNIX epoch (*_with_timestamp only)
{num_peers} the number of peers being monitored
{num_lost} the number of peers currently lost
{num_missing} the number of peers currently missing
{num_present} the number of peers currently present; not lost and not missing
{num_nonpresent} the number of peers that are either lost or missing
{timestamp} the current time in HH:MM:SS format
{datestamp} the current date and time in YYYY-MM-DD HH:MM:SS format
{version} the x.y.z version of the program

systemd

The program is preferably run as a systemd service, to have it be automatically restarted upon restoration of power. To facilitate this, a service unit file is provided in the repository.

It will have to be copied (or symlinked) into /etc/systemd/system, after which you can use systemctl edit to create a drop-in file that overrides the ExecStart directive in the unit file to point to the actual location of the wg_monitor binary. This is not required if the binary is already located in the default path of /usr/local/bin/wg_monitor.

You can find the binaries you compile with Cargo in the target/<profile>/ subdirectory of the project, where <profile> is either debug or release, depending on what profile you built with.

sudo cp wg_monitor.service /etc/systemd/system
sudo systemctl edit wg_monitor.service
### Editing /etc/systemd/system/wg_monitor.service.d/override.conf
### Anything between here and the comment below will become the contents of the drop-in file

[Service]
ExecStart=
ExecStart=/home/user/src/wg_monitor/wg_monitor --disable-timestamps --verbose --sleep 2m30s

### Edits below this comment will be discarded
### ...

An empty ExecStart= must be used to clear the value set in the original file, as Exec directives are additive.

sudo systemctl daemon-reload
sudo systemctl enable --now wg_monitor.service

enable --now both enables the service to be autostarted on subsequent boots as well as starts it immediately. For the terminal output of the program (and error messages if it could not be started), refer to the systemd journal in which such is tracked.

journalctl -b0 -fn100 -u wg_monitor.service

except it starts too early

The systemd service is set up to start the program as soon as a network connection has been established, and it might still take a while for peers to connect and handshake. Any peers that have not completed this handshake at least once when the program eventually starts will immediately be reported as missing.

To combat this, you can use the --sleep flag to have the program wait for a specified duration before starting the monitoring loop. The flag takes a human-readable duration string with a unit as argument, like 10s, 1m, 2h and so forth.

[Service]
ExecStart=
ExecStart=/home/user/src/wg_monitor/wg_monitor --disable-timestamps --verbose --sleep 5m

The included service file already passes --sleep 2m30s, but you can modify it using systemctl edit if it feels too long or too short. It should ideally be longer than two minutes to give peers enough time to complete their first handshake, but not too long to delay notifications unnecessarily.

ai

GitHub Copilot AI was used (in Visual Studio Code) for inline suggestions and to tab-complete some code and documentation. ChatGPT and Claude were used to answer questions and teach Rust. No code from "write me a function doing xyz" prompts is included in this project.

todo

  • currently nothing, ideas welcome

license

This project is dual-licensed under the MIT License and the Apache License (Version 2.0) at your option.

About

Monitors other peers in a WireGuard VPN and sends a notification if contact with a peer is lost

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

Watchers

Forks

Contributors