diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fa68aac
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+# os files
+.DS_Store
+
+# working directories
+local/
+
+# terraform files
+.terraform/
+.terraform.*
+*.tfstate
+*.tfstate.backup
+*.tfvars
+
+# ansible files
+*.retry
diff --git a/01-ssh.tf b/01-ssh.tf
new file mode 100644
index 0000000..95797d3
--- /dev/null
+++ b/01-ssh.tf
@@ -0,0 +1,13 @@
+resource "local_file" "ssh_config" {
+ content = templatefile("templates/ssh.cfg.tpl", {
+ server_name = var.vpn_server.name,
+ server_ip = var.vpn_server.local_ip,
+ ssh_username = var.vpn_server.username,
+ ssh_key_file = var.vpn_server.ssh_key_file
+ })
+
+ filename = "local/ssh.cfg"
+ file_permission = "0640"
+}
+
+
diff --git a/02-ansible.tf b/02-ansible.tf
new file mode 100644
index 0000000..dc084e7
--- /dev/null
+++ b/02-ansible.tf
@@ -0,0 +1,66 @@
+## render the run script
+
+resource "local_file" "run_playbook" {
+ content = templatefile("templates/ansible/run-ansible.sh.tpl", {
+ inventory_file = "inventory.ini"
+ })
+ filename = "local/ansible/run-ansible.sh"
+ file_permission = "0755"
+}
+
+
+## render the playbook
+
+resource "local_file" "playbook" {
+ content = templatefile("templates/ansible/playbook.yml.tpl", {
+ gateway_role = local.gateway_role,
+ iptables_role = local.iptables_role,
+ pimd_role = local.pimd_role,
+ wireguard_role = local.wireguard_role
+ })
+ filename = "local/ansible/playbook.yml"
+}
+
+
+## render host variables
+
+resource "local_file" "hostvars" {
+
+ content = templatefile("templates/ansible/hostvars.yml.tpl", {
+ server_name = var.vpn_server.name,
+ server_ifname = var.vpn_server.interface,
+ private_ip = var.vpn_server.local_ip,
+ cidr_block = var.local_network.cidr_block
+ gateway = var.local_network.gateway
+
+ vpn_endpoint_address = var.vpn_network.endpoint,
+ vpn_endpoint_port = var.vpn_network.listen_port
+
+ vpn_cidr_block = var.vpn_network.cidr_block
+ vpn_netlen = split("/", var.vpn_network.cidr_block)[1]
+ vpn_ip = local.vpn_server_vpn_ip
+ vpn_private_key = wireguard_asymmetric_key.vpn_server.private_key,
+
+ clients = [for client in var.vpn_clients :
+ {
+ name = client
+ vpn_ip = local.vpn_client_vpn_ips[index(var.vpn_clients, client)]
+ public_key = wireguard_asymmetric_key.vpn_clients[client].public_key
+ }
+ ]
+ })
+
+ filename = "local/ansible/host_vars/${var.vpn_server.name}.yml"
+ file_permission = "0640"
+}
+
+
+## render the inventory file
+
+resource "local_file" "inventory" {
+ content = templatefile("templates/ansible/inventory.ini.tpl", {
+ server = var.vpn_server.name
+ })
+ filename = "local/ansible/inventory.ini"
+ file_permission = "0640"
+}
diff --git a/03-gateway.tf b/03-gateway.tf
new file mode 100644
index 0000000..1308553
--- /dev/null
+++ b/03-gateway.tf
@@ -0,0 +1,27 @@
+
+locals {
+ gateway_role = "gateway"
+ iptables_role = "iptables"
+ pimd_role = "pimd"
+}
+
+resource "template_dir" "gateway" {
+ source_dir = "templates/ansible-roles/${local.gateway_role}"
+ destination_dir = "local/ansible/roles/${local.gateway_role}"
+
+ vars = {}
+}
+
+resource "template_dir" "iptables" {
+ source_dir = "templates/ansible-roles/${local.iptables_role}"
+ destination_dir = "local/ansible/roles/${local.iptables_role}"
+
+ vars = {}
+}
+
+resource "template_dir" "pimd" {
+ source_dir = "templates/ansible-roles/${local.pimd_role}"
+ destination_dir = "local/ansible/roles/${local.pimd_role}"
+
+ vars = {}
+}
diff --git a/04-wireguard-server.tf b/04-wireguard-server.tf
new file mode 100644
index 0000000..c168c19
--- /dev/null
+++ b/04-wireguard-server.tf
@@ -0,0 +1,19 @@
+
+locals {
+ wireguard_role = "wireguard"
+}
+
+resource "wireguard_asymmetric_key" "vpn_server" {
+}
+
+resource "wireguard_asymmetric_key" "vpn_clients" {
+ for_each = toset(var.vpn_clients)
+}
+
+
+resource "template_dir" "wireguard" {
+ source_dir = "templates/ansible-roles/${local.wireguard_role}"
+ destination_dir = "local/ansible/roles/${local.wireguard_role}"
+
+ vars = {}
+}
diff --git a/05-wireguard-clients.tf b/05-wireguard-clients.tf
new file mode 100644
index 0000000..dddc3b0
--- /dev/null
+++ b/05-wireguard-clients.tf
@@ -0,0 +1,18 @@
+
+resource "local_file" "client_configs" {
+ for_each = toset(var.vpn_clients)
+
+ content = templatefile("templates/wireguard-client.conf.tpl", {
+ vpn_ip = local.vpn_client_vpn_ips[index(var.vpn_clients, each.key)]
+ vpn_netlen = split("/", var.vpn_network.cidr_block)[1]
+ vpn_private_key = wireguard_asymmetric_key.vpn_clients[each.key].private_key,
+
+ vpn_endpoint = var.vpn_network.endpoint
+ vpn_endpoint_port = var.vpn_network.listen_port
+ vpn_endpoint_public_key = wireguard_asymmetric_key.vpn_server.public_key
+ })
+
+ filename = "local/clients/${each.key}.conf"
+ file_permission = "0640"
+}
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d8580e9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 Paul Ryan
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bbd43fa
--- /dev/null
+++ b/README.md
@@ -0,0 +1,268 @@
+# Sonos over VPN
+
+This project describes how to remotely access and control your Sonos speakers using the app over a VPN
+connection into your home network. The final build looks like this:
+
+![Network Diagram](docs/sonos-vpn.png)
+
+These instructions assume that you run a very simple network with just your ISP provided router on your local
+network. If you're doing something more advanced, the details below will be different.
+
+## Challenges
+
+There are three challenges to overcome to get this to work:
+
+* the app only works on wifi networks
+* the multicast service discovery protocol doesn't cross network boundaries
+* return traffic from the speakers to the app use the app's VPN IP address
+
+The build and operational instructions below address all these issues.
+
+Once the app has discovered the speakers and the speakers know about the app, the traffic seems to be
+unicast traffic and it all works smoothly... it's the discovery and setting up the initial connection
+that has special challenges.
+
+### Challenge #1: Wifi Network
+
+The app checks if your device is on a wifi network, and if it isn't, then it refuses to operate.
+
+The way I use it is, as shown in the diagram above, to setup a hotspot on my mobile phone and then connect
+my tablet to it and start the VPN on my tablet. When that's all up, I can start the Sonos app and control
+the speakers.
+
+### Challenge #2: Multicast Service Discovery
+
+Multicast packets aren't normally forwarded by routers (in this case, the RaspberryPi VPN server) which
+means that by default, the discovery packets arriving at your VPN server will be dropped and never make it
+into your home network to find your sonos speakers.
+
+This is solved with these two steps:
+
+* incrementing the multicast packet TTL so it can be forwarded into the home network
+* installing a multicast router on the VPN server to do the forwarding
+
+An 'iptables' rule (in the 'mangle' table) is installed to increment the packet TTL. If you were to install
+the rule manually, the command would look something like this:
+
+ iptables -t mangle -A POSTROUTING -o eth0 -j TTL --ttl-inc 1
+
+The ansible scripts install the multicast router 'pimd' on the vpn server to forward the packets.
+It works with the default installation and configuration.
+
+These two steps are handled automatically by the ansible setup.
+
+### Challenge #3: Return Traffic to App Host's VPN IP Address
+
+It appears that the multicast service discovery protocol being used sends the IP address and port of
+the device running the app as part of the packet payload. This means the speakers always want to talk
+back to the app on it's actual VPN IP address.
+
+There are two consequences of this:
+
+* can't have NAT anywhere between the app and the speakers
+* the speakers need to be able to route traffic back to the VPN network
+
+There are a number of ways to make this work and they are things you will need to do manually. The approach
+documented below uses static routes on your ISP provided router.
+
+## Prerequisites
+
+### Router Capabilities
+
+There are two essential services this setup requires from your router:
+
+* port forwarding from the internet side of your ISP provided router to an internal machine
+* the ability to configure static routes
+
+How you set this up will vary between routers, but here's how it is on mine.
+
+Port forwarding is pretty standard: from the web management console, browse to:
+
+ 'Security' -> 'Apps and Gaming' -> 'Single Port Forwarding' -> 'Add new Single Port Forwarding'
+
+For static routes, from the web management console, browse to:
+
+ 'Connectivity' -> 'Advanced Routing' -> 'Add Static Route'
+
+If you don't have equivalent features, then this isn't going to work for you.
+
+### Hardware
+
+I built my setup on a RaspberryPi 3 Model B+ with the Bullseye OS, 32bit lite version installed.
+It will work on lots of other systems, but you will most likely need to adapt some things.
+
+Build the OS with the RaspberryPi Imager as described [here](https://www.raspberrypi.com/software/).
+
+Set it up with ssh access enabled using public-key authentication.
+
+Once it's built update the system with these commands:
+
+ sudo apt update
+ sudo apt dist-upgrade
+ sudo reboot
+
+Once you can ssh into the server without being prompted for a password, you're ready to go.
+
+### Software
+
+The project uses [terraform](https://www.terraform.io/) and [ansible](https://www.ansible.com/) to
+build the configs and configure the raspberry pi server.
+
+I used these versions:
+
+* terraform (v1.3.2)
+* ansible (core 2.13.5)
+
+## Preparation
+
+In the below sections, the following network addresses are used:
+
+* home network: 192.168.1.0/24
+* raspberry pi server: 192.168.1.21
+* vpn network: 192.168.15.0/24
+
+If your networks are different, you will need to use your correct settings in the below steps.
+
+### Create DHCP Reservation for RaspberryPi Server
+
+This step ensures that the IP address of your RaspberryPi won't change - it needs to be a well known
+address so that VPN traffic can be forwarded to it.
+
+The first step is to find the the MAC and IP addresses of your server with this command:
+
+ ifconfig eth0
+
+The two lines of interest look like this:
+
+ inet 192.168.1.21 netmask 255.255.255.0 broadcast 192.168.1.255
+ ether b8:27:eb:81:53:de txqueuelen 1000 (Ethernet)
+
+Log into your ISP provided router and create the reservation. For my router, the process was to navigate
+through the menus to: 'Connectivity' -> 'Local Network' and select 'DHCP Reservations'.
+Enter the name, MAC address and IP address from above. For the example above, these values are:
+
+ MAC address: b8:27:eb:81:53:de
+ IP address: 192.168.1.21
+
+Reboot your RaspberryPi and verify that the IP address is correctly set.
+
+### Port Forwarding
+
+In the router web management console, browse to: 'Security' -> 'Apps and Gaming' -> 'Single Port Forwarding'
+and select 'Add new Single Port Forwarding'. Enter the following in the dialog:
+
+ Name: SonosVPN
+ Protocol: UDP
+ WAN Port: 51820
+ LAN POrt: 51820
+ Destination IP: 192.168.1.21
+
+If you have changed the port in the configuration, you'll need to change those ports here as well.
+
+### Static Routes
+
+In the router web management console, browse to: 'Connectivity' -> 'Advanced Routing' and select 'Add Static Route'.
+Add the following (assuming you're using default VPN network settings):
+
+ Route Name: SonosVPN
+ Destination IP: 192.168.15.0
+ Subnet Mask: 255.255.255.0
+ Gateway: 192.168.1.21
+
+If you have configured a different 'cidr_block' for the 'vpn_network' you will need to use the new values
+in the 'DestinationIP' and 'Subnet Mask' fields.
+
+### VPN Endpoint
+
+To use your VPN, you need a way to access the endpoint from outside your home/work network - this can
+be either a DNS name or an IP address.
+
+For the DNS name, you can use a dynamic DNS provider and a lot of ISP provided routers have built in support
+for dynamic DNS. Now's a good time to set that up if you plan on using it.
+
+This can also just be an IP address. However, this will probably change from time to time so you'll need
+to update it in your client config occasionally. A simple way to get your network's external, public IP address is
+with this:
+
+ dig @resolver1.opendns.com +short myip.opendns.com
+
+You will need this IP address to complete the steps below.
+
+## Build
+
+There are two steps to building this system:
+
+* use terraform to create the ansible and wireguard configs
+* use ansible to configure the server
+
+### Terraform
+
+Terraform is where the configurations of your local network, the vpn network and the wireguard clients
+are defined.
+
+Copy the file 'terraform.tfvars.examples' to 'terraform.tfvars' and modify it for your configuration. There
+are four configuration blocks that need to be set, describing:
+
+* the home network
+* the vpn network
+* the vpn server
+* the vpn clients
+
+You can add as many clients as you like to the client list and terraform will create a configuration
+for each.
+
+To run terraform:
+
+ terraform init
+ terraform apply
+
+Once it finishes, the output is written to the directory 'local'. The client configurations can be found
+in the 'clients' subdirectory.
+
+### Ansible
+
+Configuring the VPN server is fully automatic and quite straightforward. Simply run this command from
+the root directory of the repository and it will set it up:
+
+ ./local/ansible/run-ansible.sh
+
+If you want to see what it's doing first, start with the file 'playbook.yml' and read through the roles;
+there isn't a lot to it.
+
+
+## Using the System
+
+### Wireguard Client
+
+In the 'local/clients' directory, there are configurations for each client. They look like this:
+
+ [Interface]
+ Address = 192.168.15.7/24
+ PrivateKey =
+ DNS = 1.1.1.1
+
+ [Peer]
+ PublicKey =
+ AllowedIPs = 0.0.0.0/0
+ Endpoint = AAA.BBB.CCC.DDD:51820
+
+To install this configuration on a iPad assuming you're running on a mac, follow these steps:
+
+* copy the client configuration to your icloud drive
+* on your ipad, download the wireguard app from the app store
+* click the '+' button and choose 'create from file or archive'
+* browse to your icloud drive and select the configuration
+
+That will install the vpn profile and you should be ready to go.
+
+### Connection and Operating
+
+To use the system:
+
+* enable the personal hotspot on your mobile phone
+* connect your tablet's wifi to the hotspot
+* start the vpn on your tablet
+* start your sonos app on your tablet and wait for it to discover your speakers
+
+You might need to fully kill the app and start it again so it isn't trying to use any cached settings.
+
diff --git a/docs/sonos-vpn.png b/docs/sonos-vpn.png
new file mode 100644
index 0000000..306ca82
Binary files /dev/null and b/docs/sonos-vpn.png differ
diff --git a/outputs.tf b/outputs.tf
new file mode 100644
index 0000000..3b17007
--- /dev/null
+++ b/outputs.tf
@@ -0,0 +1,25 @@
+
+/*output "local_network" {
+ value = var.local_network
+}
+
+output "vpn_network" {
+ value = var.vpn_network
+}
+
+output "vpn_server" {
+ value = var.vpn_server
+}
+
+output "vpn_clients" {
+ value = var.vpn_clients
+}
+
+output "vpn_server_vpn_ip" {
+ value = local.vpn_server_vpn_ip
+}
+
+output "vpn_client_vpn_ips" {
+ value = local.vpn_client_vpn_ips
+}*/
+
diff --git a/templates/ansible-roles/gateway/tasks/main.yml b/templates/ansible-roles/gateway/tasks/main.yml
new file mode 100644
index 0000000..dc6f96a
--- /dev/null
+++ b/templates/ansible-roles/gateway/tasks/main.yml
@@ -0,0 +1,29 @@
+## configure to forward packets
+
+- name: enable packet forwarding
+ sysctl:
+ name: net.ipv4.ip_forward
+ value: 1
+
+- name: enable secure redirects
+ sysctl:
+ name: net.ipv4.conf.all.secure_redirects
+ value: 1
+
+- name: disable accept redirects
+ sysctl:
+ name: net.ipv4.conf.all.accept_redirects
+ value: 0
+
+- name: disable source routing
+ sysctl:
+ name: net.ipv4.conf.all.accept_source_route
+ value: 0
+
+- name: disable send redirects
+ sysctl:
+ name: net.ipv4.conf.all.send_redirects
+ value: 0
+
+
+
diff --git a/templates/ansible-roles/iptables/handlers/main.yml b/templates/ansible-roles/iptables/handlers/main.yml
new file mode 100644
index 0000000..d166e72
--- /dev/null
+++ b/templates/ansible-roles/iptables/handlers/main.yml
@@ -0,0 +1,6 @@
+- name: restore iptables v4
+ shell: /sbin/iptables-restore < /etc/iptables/rules.v4
+
+- name: restore iptables v6
+ shell: /sbin/ip6tables-restore < /etc/iptables/rules.v6
+
diff --git a/templates/ansible-roles/iptables/tasks/main.yml b/templates/ansible-roles/iptables/tasks/main.yml
new file mode 100644
index 0000000..a90fb97
--- /dev/null
+++ b/templates/ansible-roles/iptables/tasks/main.yml
@@ -0,0 +1,25 @@
+- name: install iptables-persistent
+ apt:
+ name: iptables-persistent
+ state: present
+
+- name: install iptable v4 rules
+ template:
+ src: rules.v4
+ dest: /etc/iptables/rules.v4
+ owner: root
+ group: root
+ mode: 0644
+ notify:
+ - restore iptables v4
+
+- name: install iptable v6 rules
+ template:
+ src: rules.v6
+ dest: /etc/iptables/rules.v6
+ owner: root
+ group: root
+ mode: 0644
+ notify:
+ - restore iptables v6
+
diff --git a/templates/ansible-roles/iptables/templates/rules.v4 b/templates/ansible-roles/iptables/templates/rules.v4
new file mode 100644
index 0000000..080f9cd
--- /dev/null
+++ b/templates/ansible-roles/iptables/templates/rules.v4
@@ -0,0 +1,22 @@
+*filter
+:INPUT ACCEPT [0:0]
+:FORWARD ACCEPT [0:0]
+:OUTPUT ACCEPT [0:0]
+COMMIT
+
+*nat
+:PREROUTING ACCEPT [0:0]
+:INPUT ACCEPT [0:0]
+:OUTPUT ACCEPT [0:0]
+:POSTROUTING ACCEPT [0:0]
+-A POSTROUTING -o eth0 -d {{ gateway }}/32 -j MASQUERADE
+-A POSTROUTING -o eth0 ! -d {{ cidr_block }} -j MASQUERADE
+COMMIT
+
+*mangle
+:PREROUTING ACCEPT [0:0]
+:INPUT ACCEPT [0:0]
+:OUTPUT ACCEPT [0:0]
+:POSTROUTING ACCEPT [0:0]
+-A POSTROUTING -o {{ server_ifname }} -j TTL --ttl-inc 1
+COMMIT
diff --git a/templates/ansible-roles/iptables/templates/rules.v6 b/templates/ansible-roles/iptables/templates/rules.v6
new file mode 100644
index 0000000..b4a831e
--- /dev/null
+++ b/templates/ansible-roles/iptables/templates/rules.v6
@@ -0,0 +1,6 @@
+*filter
+:INPUT ACCEPT [0:0]
+:FORWARD DROP [0:0]
+:OUTPUT ACCEPT [0:0]
+COMMIT
+
diff --git a/templates/ansible-roles/pimd/tasks/main.yml b/templates/ansible-roles/pimd/tasks/main.yml
new file mode 100644
index 0000000..50f02cc
--- /dev/null
+++ b/templates/ansible-roles/pimd/tasks/main.yml
@@ -0,0 +1,13 @@
+## install packages
+
+- name: install pimd
+ apt:
+ name: pimd
+ state: present
+
+# wireguard will start/stop this
+- name: stop pimd
+ systemd:
+ name: pimd
+ state: stopped
+ enabled: false
diff --git a/templates/ansible-roles/wireguard/handlers/main.yml b/templates/ansible-roles/wireguard/handlers/main.yml
new file mode 100644
index 0000000..7bd672f
--- /dev/null
+++ b/templates/ansible-roles/wireguard/handlers/main.yml
@@ -0,0 +1,6 @@
+
+- name: restart wireguard
+ systemd:
+ name: wg-quick@wg0
+ state: restarted
+
diff --git a/templates/ansible-roles/wireguard/tasks/main.yml b/templates/ansible-roles/wireguard/tasks/main.yml
new file mode 100644
index 0000000..a4d5cdb
--- /dev/null
+++ b/templates/ansible-roles/wireguard/tasks/main.yml
@@ -0,0 +1,49 @@
+- name: install wireguard-tools
+ apt:
+ name: wireguard-tools
+ state: present
+
+- name: install wireguard config file
+ template:
+ src: wireguard.conf
+ dest: /etc/wireguard/wg0.conf
+ owner: root
+ group: root
+ mode: 0440
+ notify:
+ - restart wireguard
+
+- name: create wireguard script directory
+ file:
+ path: /etc/wireguard/scripts
+ state: directory
+ owner: root
+ group: root
+ mode: 0750
+
+- name: install wireguard post-up script
+ template:
+ src: post-up.sh
+ dest: /etc/wireguard/scripts/post-up.sh
+ owner: root
+ group: root
+ mode: 0750
+ notify:
+ - restart wireguard
+
+- name: install wireguard pre-down script
+ template:
+ src: pre-down.sh
+ dest: /etc/wireguard/scripts/pre-down.sh
+ owner: root
+ group: root
+ mode: 0750
+ notify:
+ - restart wireguard
+
+- name: start wireguard
+ systemd:
+ name: wg-quick@wg0
+ state: restarted
+ enabled: yes
+ daemon_reload: yes
diff --git a/templates/ansible-roles/wireguard/templates/post-up.sh b/templates/ansible-roles/wireguard/templates/post-up.sh
new file mode 100755
index 0000000..1e7fefd
--- /dev/null
+++ b/templates/ansible-roles/wireguard/templates/post-up.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+
+systemctl start pimd
+
diff --git a/templates/ansible-roles/wireguard/templates/pre-down.sh b/templates/ansible-roles/wireguard/templates/pre-down.sh
new file mode 100755
index 0000000..a6008bb
--- /dev/null
+++ b/templates/ansible-roles/wireguard/templates/pre-down.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+systemctl stop pimd
+
+
diff --git a/templates/ansible-roles/wireguard/templates/wireguard.conf b/templates/ansible-roles/wireguard/templates/wireguard.conf
new file mode 100644
index 0000000..39a0e75
--- /dev/null
+++ b/templates/ansible-roles/wireguard/templates/wireguard.conf
@@ -0,0 +1,14 @@
+[Interface]
+Address = {{ vpn_ip }}/{{ vpn_netlen }}
+ListenPort = {{ vpn_endpoint_port }}
+PrivateKey = {{ vpn_private_key }}
+PostUp = /etc/wireguard/scripts/post-up.sh
+PreDown = /etc/wireguard/scripts/pre-down.sh
+
+{% for client in clients %}
+[Peer]
+# Peer: {{ client.name }}
+PublicKey = {{ client.public_key }}
+AllowedIPs = {{ client.vpn_ip }}
+{% endfor %}
+
diff --git a/templates/ansible/hostvars.yml.tpl b/templates/ansible/hostvars.yml.tpl
new file mode 100644
index 0000000..0c4879e
--- /dev/null
+++ b/templates/ansible/hostvars.yml.tpl
@@ -0,0 +1,21 @@
+---
+server_name: ${server_name}
+server_ifname: ${server_ifname}
+private_ip: ${private_ip}
+cidr_block: ${cidr_block}
+gateway: ${gateway}
+
+vpn_endpoint_address: ${vpn_endpoint_address}
+vpn_endpoint_port: ${vpn_endpoint_port}
+
+vpn_cidr_block: ${vpn_cidr_block}
+vpn_ip: ${vpn_ip}
+vpn_netlen: ${vpn_netlen}
+vpn_private_key: ${vpn_private_key}
+
+clients:
+%{ for client in clients ~}
+- name: ${client.name}
+ vpn_ip: ${client.vpn_ip}
+ public_key: ${client.public_key}
+%{ endfor ~}
diff --git a/templates/ansible/inventory.ini.tpl b/templates/ansible/inventory.ini.tpl
new file mode 100644
index 0000000..19d6363
--- /dev/null
+++ b/templates/ansible/inventory.ini.tpl
@@ -0,0 +1,2 @@
+[servers]
+${server}
diff --git a/templates/ansible/playbook.yml.tpl b/templates/ansible/playbook.yml.tpl
new file mode 100644
index 0000000..5af2974
--- /dev/null
+++ b/templates/ansible/playbook.yml.tpl
@@ -0,0 +1,22 @@
+---
+- hosts: servers
+ become: yes
+ gather_facts: no
+
+ vars:
+ ansible_python_interpreter: "/usr/bin/env python3"
+
+ tasks:
+ - import_role:
+ name: ${gateway_role}
+
+ - import_role:
+ name: ${iptables_role}
+
+ - import_role:
+ name: ${pimd_role}
+
+ - import_role:
+ name: ${wireguard_role}
+
+
diff --git a/templates/ansible/run-ansible.sh.tpl b/templates/ansible/run-ansible.sh.tpl
new file mode 100755
index 0000000..1fa6a4e
--- /dev/null
+++ b/templates/ansible/run-ansible.sh.tpl
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+
+SCRIPT_DIR="$( cd "$( dirname "$${BASH_SOURCE[0]}" )" && pwd )"
+RUN_DIR="$( dirname $( dirname $${SCRIPT_DIR} ) )"
+cd $${RUN_DIR}
+
+export ANSIBLE_HOST_KEY_CHECKING=0
+export ANSIBLE_SSH_ARGS="-F local/ssh.cfg -C -o ControlMaster=auto -o ControlPersist=60s"
+
+ansible-playbook \
+ --inventory=$${SCRIPT_DIR}/${inventory_file} \
+ $${SCRIPT_DIR}/playbook.yml
+
diff --git a/templates/ssh.cfg.tpl b/templates/ssh.cfg.tpl
new file mode 100644
index 0000000..d49522f
--- /dev/null
+++ b/templates/ssh.cfg.tpl
@@ -0,0 +1,5 @@
+Host ${server_name}
+ Hostname ${server_ip}
+ User ${ssh_username}
+ IdentityFile ${ssh_key_file}
+ IdentitiesOnly yes
diff --git a/templates/wireguard-client.conf.tpl b/templates/wireguard-client.conf.tpl
new file mode 100644
index 0000000..e2eb95d
--- /dev/null
+++ b/templates/wireguard-client.conf.tpl
@@ -0,0 +1,9 @@
+[Interface]
+Address = ${vpn_ip }/${vpn_netlen}
+PrivateKey = ${vpn_private_key}
+DNS = 1.1.1.1
+
+[Peer]
+PublicKey = ${vpn_endpoint_public_key}
+AllowedIPs = 0.0.0.0/0
+Endpoint = ${vpn_endpoint}:${vpn_endpoint_port}
diff --git a/terraform.tf b/terraform.tf
new file mode 100644
index 0000000..aba108f
--- /dev/null
+++ b/terraform.tf
@@ -0,0 +1,13 @@
+terraform {
+ required_version = ">= 1.3.2"
+ required_providers {
+ wireguard = {
+ source = "ojford/wireguard"
+ version = "0.2.1+1"
+ }
+ }
+}
+
+
+provider "wireguard" {}
+
diff --git a/terraform.tfvars.example b/terraform.tfvars.example
new file mode 100644
index 0000000..85ec3c4
--- /dev/null
+++ b/terraform.tfvars.example
@@ -0,0 +1,25 @@
+
+local_network = {
+ cidr_block = "192.168.1.0/24"
+ gateway = "192.168.1.1"
+}
+
+vpn_network = {
+ endpoint = "sonos.mydomain.com"
+ listen_port = 51820
+ cidr_block = "192.168.15.0/24"
+}
+
+vpn_server = {
+ name = "sonos"
+ interface = "eth0"
+ local_ip = "192.168.1.21"
+ username = "pi"
+ ssh_key_file = "~/.ssh/rpi"
+}
+
+vpn_clients = [
+ "client1",
+ "client2"
+]
+
diff --git a/variables.tf b/variables.tf
new file mode 100644
index 0000000..f5f285e
--- /dev/null
+++ b/variables.tf
@@ -0,0 +1,52 @@
+
+variable "local_network" {
+ type = object({
+ cidr_block = string
+ gateway = string
+ })
+ default = {
+ cidr_block = "192.168.14.0/24"
+ gateway = "192.168.14.1"
+ }
+}
+
+variable "vpn_server" {
+ type = object({
+ name = string
+ interface = string
+ local_ip = string
+ username = string
+ ssh_key_file = string
+ })
+ default = {
+ name = "sonos"
+ interface = "eth0"
+ local_ip = "192.168.14.4"
+ username = "pi"
+ ssh_key_file = "~/.ssh/rpi"
+ }
+}
+
+variable "vpn_network" {
+ type = object({
+ endpoint = string
+ listen_port = number
+ cidr_block = string
+ })
+ default = {
+ endpoint = "sonos.mydomain.com" # DNS name or IP address
+ listen_port = 51820
+ cidr_block = "192.168.15.0/24"
+ }
+}
+
+variable "vpn_clients" {
+ type = list(string)
+ default = ["laptop", "ipad"]
+}
+
+locals {
+ vpn_server_vpn_ip = cidrhost(var.vpn_network.cidr_block, 1)
+ vpn_client_vpn_ips = [for client in var.vpn_clients : cidrhost(var.vpn_network.cidr_block, index(var.vpn_clients, client)+5)]
+}
+