Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8f54ca865 | ||
|
|
245d0dfd96 | ||
|
|
dac5cea5ee | ||
|
|
7cb2f1b8dd | ||
|
|
083ad9a596 |
@@ -4,3 +4,11 @@ out
|
|||||||
.*.md
|
.*.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
__pycache__
|
||||||
|
.vscode
|
||||||
|
tmp
|
||||||
|
.terraform*
|
||||||
|
*tfplan*
|
||||||
|
*.tfstate
|
||||||
|
*.tfstate.*
|
||||||
|
*.auto.tfvars
|
||||||
|
|||||||
@@ -1,4 +1,266 @@
|
|||||||
# Linux Cloud Router for Ubuntu/Debian Linux
|
# vpn-router
|
||||||
|
|
||||||
This project provides an Ubuntu/Debian Linux-based cloud router solution that can be deployed on various cloud platforms. It offers routing and S2S and P2S VPN capabilities, making it an ideal choice for organizations looking to establish secure and efficient network connectivity in the cloud.
|
A Debian package that configures a Linux host or virtual machine as a VPN router:
|
||||||
|
|
||||||
|
- site-to-site IKEv2 IPSec with pre-shared key authentication (strongSwan swanctl)
|
||||||
|
- road-warrior (P2S) access using IKEv2 EAP-TLS, with a bundled certificate authority
|
||||||
|
- optional WireGuard endpoint
|
||||||
|
- routing and NAT for a protected subnet, TCP MSS clamping, and UFW firewall rules
|
||||||
|
|
||||||
|
Nothing in the package is specific to a cloud provider. It is built on the external and
|
||||||
|
internal NIC names, and a `mode` setting says how much of the rest you are supplying and
|
||||||
|
how much is read from the system. Detection happens only in the mode that asks for it, so
|
||||||
|
a router never quietly guesses which interface to NAT out of unless you told it to.
|
||||||
|
Platform-specific additions live in separate modules and are selected explicitly.
|
||||||
|
|
||||||
|
Installing the package configures nothing by default. The shipped platform is `none`,
|
||||||
|
which means the files are laid down and the machine is left alone until you say
|
||||||
|
otherwise.
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
The package can be installed by hand or as a step in automated provisioning. Both are
|
||||||
|
supported equally, and both end up with the same two things: a configuration file and a
|
||||||
|
service that applies it.
|
||||||
|
|
||||||
|
```
|
||||||
|
/etc/vpn-router/vpn-router.conf the configuration
|
||||||
|
systemctl restart vpn-router-setup apply it
|
||||||
|
```
|
||||||
|
|
||||||
|
### By hand
|
||||||
|
|
||||||
|
```sh
|
||||||
|
apt-get install vpn-router # answer "none" to the platform question
|
||||||
|
$EDITOR /etc/vpn-router/vpn-router.conf
|
||||||
|
systemctl restart vpn-router-setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Answering `none` to the platform question defers everything: no further questions are
|
||||||
|
asked and nothing on the machine is configured. The only change an install makes is the
|
||||||
|
package's own `/etc/sysctl.d/` drop-in, which enables IP forwarding and relaxes
|
||||||
|
`rp_filter` as a router needs. Set `platform` in the file when you are ready.
|
||||||
|
|
||||||
|
### Automated
|
||||||
|
|
||||||
|
Preseed the answers and the router comes up configured, with no follow-up command. Note
|
||||||
|
that `platform` must be something other than `none`, and `mode` says how much you are
|
||||||
|
supplying:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
debconf-set-selections <<'EOF'
|
||||||
|
vpn-router vpn-router/platform select generic
|
||||||
|
vpn-router vpn-router/mode select manual
|
||||||
|
vpn-router vpn-router/external_interface string eth0
|
||||||
|
vpn-router vpn-router/internal_interface string eth1
|
||||||
|
vpn-router vpn-router/int_addr string 10.1.1.4
|
||||||
|
vpn-router vpn-router/int_gateway_ip string 10.1.1.1
|
||||||
|
vpn-router vpn-router/local_fqdn string router.example.com
|
||||||
|
vpn-router vpn-router/local_cidrs string 10.0.0.0/24
|
||||||
|
vpn-router vpn-router/remote_addrs string peer.example.net
|
||||||
|
vpn-router vpn-router/remote_id string peer.example.net
|
||||||
|
vpn-router vpn-router/remote_cidrs string 192.168.0.0/24
|
||||||
|
vpn-router vpn-router/psk password s3cr3t
|
||||||
|
EOF
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y vpn-router
|
||||||
|
```
|
||||||
|
|
||||||
|
With `mode = interfaces` the `int_addr` and `int_gateway_ip` lines are dropped and read
|
||||||
|
from the internal NIC instead; with `mode = auto` the interface names go too.
|
||||||
|
|
||||||
|
Alternatively write `/etc/vpn-router/vpn-router.conf` directly before or after installing.
|
||||||
|
A configuration-management tool should do that and then restart `vpn-router-setup`.
|
||||||
|
|
||||||
|
See [examples/](examples/) for a cloud-init template and a shell installer, and
|
||||||
|
[examples/azure/](examples/azure/) / [examples/gcp/](examples/gcp/) for full Terraform examples
|
||||||
|
that deploy a router on Azure or GCP.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`/etc/vpn-router/vpn-router.conf` is INI, mode 0600, and is created on first install. It
|
||||||
|
is managed with `ucf`, so it is yours to edit: a later `dpkg-reconfigure` applies what
|
||||||
|
changed and asks before touching anything you altered by hand. A fully commented
|
||||||
|
reference is installed at `/usr/share/doc/vpn-router/vpn-router.conf.example`.
|
||||||
|
|
||||||
|
Lists are comma-separated, booleans are `true`/`false`, an empty value means unset, and a
|
||||||
|
`_b64` suffix means the value is base64-encoded.
|
||||||
|
|
||||||
|
| Section | Setting | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `general` | `platform` | `none`, `generic`, `azure` or `gcp`. `none` defers configuration entirely |
|
||||||
|
| `general` | `mode` | How much you supply: `manual`, `interfaces` or `auto` |
|
||||||
|
| `interfaces` | `external` | Name of the NIC facing the untrusted network |
|
||||||
|
| `interfaces` | `internal` | Name of the NIC facing the protected network |
|
||||||
|
| `wan` | `local_fqdn` | This router's FQDN |
|
||||||
|
| `wan` | `local_id_mode` | IKE identity source: `fqdn`, `public_ip` or `internal_ip` |
|
||||||
|
| `local` | `cidrs` | Local subnets advertised into the tunnel |
|
||||||
|
| `local` | `int_addr` | This host's address on the internal network |
|
||||||
|
| `local` | `int_gateway_ip` | Next hop on the internal side |
|
||||||
|
| `remote` | `addrs` | Remote gateway address(es) or FQDN |
|
||||||
|
| `remote` | `id` | Remote IKE identity, without a leading `@` |
|
||||||
|
| `remote` | `cidrs` | Remote subnets reachable through the tunnel |
|
||||||
|
| `remote` | `psk_b64` | Pre-shared key, base64-encoded |
|
||||||
|
| `remote` | `psk_file` | Path to a file holding the raw key; wins over `psk_b64` |
|
||||||
|
| `p2s` | `enabled` | Road-warrior access |
|
||||||
|
| `p2s` | `address_pool` | Pool assigned to road-warrior clients |
|
||||||
|
| `p2s` | `ca_name` | Common name for a locally created CA |
|
||||||
|
| `wireguard` | `enabled` | WireGuard endpoint |
|
||||||
|
| `wireguard` | `address` | Address and prefix for `wg0` |
|
||||||
|
| `wireguard` | `listen_port` | UDP port, default 51820 |
|
||||||
|
|
||||||
|
WireGuard peers are added by hand in `/etc/wireguard/wg0.conf`. The package owns the
|
||||||
|
`[Interface]` section of that file and rewrites it when the settings above change, but it
|
||||||
|
carries every `[Peer]` section across untouched.
|
||||||
|
|
||||||
|
Encode the pre-shared key with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
printf %s 'the key' | base64 -w0
|
||||||
|
```
|
||||||
|
|
||||||
|
Every feature is independent. Leaving a group empty simply means that feature is not
|
||||||
|
configured, and its generated files are removed.
|
||||||
|
|
||||||
|
### What `mode` decides
|
||||||
|
|
||||||
|
`mode` states how much of the network configuration you are supplying, and therefore how
|
||||||
|
much the package reads from the system:
|
||||||
|
|
||||||
|
| `mode` | You provide | The package works out |
|
||||||
|
|---|---|---|
|
||||||
|
| `manual` | both interface names, `int_addr`, `int_gateway_ip` | nothing |
|
||||||
|
| `interfaces` | both interface names | `int_addr` and `int_gateway_ip`, from those interfaces |
|
||||||
|
| `auto` | nothing | the interface names too, from the routing table |
|
||||||
|
|
||||||
|
`manual` is the only mode in which no detection code runs at all. `auto` is the
|
||||||
|
convenient one and can pick the wrong interface, which is a trade you make deliberately
|
||||||
|
by choosing it rather than something the package does behind your back.
|
||||||
|
|
||||||
|
The addresses strongSwan binds on always come from the external NIC, in every mode, so
|
||||||
|
they are never configured twice and cannot drift out of step with the host. If a named
|
||||||
|
interface does not exist the service stops and says so. In `interfaces` and `auto` mode
|
||||||
|
the internal gateway is taken from the routing table, falling back to the first host
|
||||||
|
address of the connected subnet with a warning when the routing table has nothing to say.
|
||||||
|
|
||||||
|
## Applying changes
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl restart vpn-router-setup
|
||||||
|
```
|
||||||
|
|
||||||
|
The service also runs at boot. With `platform = none` it does nothing and says so.
|
||||||
|
Otherwise it regenerates the configuration files, resolves the interfaces and their
|
||||||
|
addresses as `mode` directs, applies routes, generates WireGuard keys and injects
|
||||||
|
firewall rules. It then starts `strongswan` and `wg-quick@wg0` if the corresponding
|
||||||
|
feature is configured, reloading or restarting them only when their inputs changed, and
|
||||||
|
restarts `systemd-resolved` only when the road-warrior DNS drop-in changed. Turning a
|
||||||
|
feature off in the configuration stops its service and removes its files. Every step is
|
||||||
|
idempotent, so restarting with no changes does nothing.
|
||||||
|
|
||||||
|
To regenerate the configuration files and PKI without starting, stopping or reloading
|
||||||
|
anything:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/usr/lib/vpn-router/configure
|
||||||
|
```
|
||||||
|
|
||||||
|
This does still write to the system: it renders into `/etc/swanctl/`,
|
||||||
|
`/etc/systemd/resolved.conf.d/` and `/etc/wireguard/`, and if road-warrior access is
|
||||||
|
enabled with an empty PKI directory it creates the certificate authority and adds it
|
||||||
|
to the host trust store. What it leaves alone is routes, firewall rules and services -
|
||||||
|
that is what `systemctl restart vpn-router-setup` adds.
|
||||||
|
|
||||||
|
## Certificates
|
||||||
|
|
||||||
|
`/etc/vpn-router/pki` is the PKI directory, laid out the way `simple-ca` expects:
|
||||||
|
|
||||||
|
```
|
||||||
|
ca_cert.pem ca_key.pem ca_bundle.pem <label>_cert.pem <label>_key.pem crl.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
`<label>` is the first component of the router FQDN, so `router.example.com` gives
|
||||||
|
`router_cert.pem`.
|
||||||
|
|
||||||
|
If road-warrior access is enabled and this directory is empty, a certificate authority and
|
||||||
|
a server certificate are created there automatically. To use your own instead, place the
|
||||||
|
files in that layout before enabling the feature; nothing is generated or overwritten when
|
||||||
|
material is already present.
|
||||||
|
|
||||||
|
Either way, the package copies what each consumer needs into place: the CA into
|
||||||
|
`/etc/swanctl/x509ca/`, the server certificate and key into `/etc/swanctl/x509/` and
|
||||||
|
`/etc/swanctl/private/`, a CRL into `/etc/swanctl/x509crl/`, and the CA into the system
|
||||||
|
trust store.
|
||||||
|
|
||||||
|
Issue a client certificate with the bundled tool:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/usr/lib/vpn-router/simple-ca make-cert --ca-dir /etc/vpn-router/pki alice
|
||||||
|
/usr/lib/vpn-router/simple-ca make-pfx --ca-dir /etc/vpn-router/pki \
|
||||||
|
--password s3cr3t /etc/vpn-router/pki/alice_cert.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
`simple-ca` comes from a separate project, <https://gitea.koszewscy.waw.pl/slawek/simple-ca>,
|
||||||
|
and also supports `make-crl` and `revoke-cert`.
|
||||||
|
|
||||||
|
## Platform modules
|
||||||
|
|
||||||
|
Platform-specific values do not belong in the core, so they are Python modules in
|
||||||
|
`/usr/lib/vpn-router/vpnrouter_platforms/`, imported at run time and selected by
|
||||||
|
`general.platform`. The platform is never probed: the caller already knows where it is
|
||||||
|
deploying.
|
||||||
|
|
||||||
|
`none` is not a module. It is the shipped default and means "configure nothing at all",
|
||||||
|
which is how a deferred install is expressed. `generic` is the module to choose when the
|
||||||
|
router should be configured but the platform adds nothing.
|
||||||
|
|
||||||
|
The shipped modules currently add nothing. Road-warrior clients are given the router
|
||||||
|
itself as their DNS server and the router forwards through its own `systemd-resolved`, so
|
||||||
|
no provider resolver has to be advertised or routed through the tunnel on any platform.
|
||||||
|
The modules exist as the documented place for platform specifics if that changes.
|
||||||
|
|
||||||
|
A module may define any of the following, and takes the default for anything it omits:
|
||||||
|
|
||||||
|
| Name | Type | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `EXTRA_P2S_DNS` | `str` | added to the road-warrior pool `dns` list |
|
||||||
|
| `EXTRA_LOCAL_TS` | `str` | added to `local_ts` on both connections |
|
||||||
|
| `apply(ctx)` | function | called once the system is configured, to apply platform-specific state; must be idempotent |
|
||||||
|
|
||||||
|
`ctx` is the template context, so a module reads what it needs from it -
|
||||||
|
`ctx['wan_iface']`, `ctx['int_iface']`, `ctx['local_cidrs']` and the rest.
|
||||||
|
|
||||||
|
Adding a platform is adding one file. Modules are discovered by listing the package, so
|
||||||
|
nothing in the core needs editing:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# /usr/lib/vpn-router/vpnrouter_platforms/example.py
|
||||||
|
"""Example provider."""
|
||||||
|
|
||||||
|
EXTRA_P2S_DNS = '192.0.2.53'
|
||||||
|
EXTRA_LOCAL_TS = '192.0.2.53/32'
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `platform = example` and restart the service. The debconf question offers only the
|
||||||
|
modules shipped with the package; the configuration file accepts any module installed.
|
||||||
|
|
||||||
|
## Removal
|
||||||
|
|
||||||
|
`apt-get remove` terminates the tunnels and takes `wg-quick@wg0` down, but leaves the
|
||||||
|
configuration in place. `strongswan` is left enabled, since it is a shared service that
|
||||||
|
may be in use by something else.
|
||||||
|
|
||||||
|
`apt-get purge` additionally removes the generated files and the firewall rules, restoring
|
||||||
|
`/etc/ufw/before.rules` to its original content, and hands `vpn-router.conf` back to `ucf`
|
||||||
|
before deleting it so a later reinstall starts clean. Key material is never deleted:
|
||||||
|
`/etc/vpn-router/pki`, the WireGuard key pair and everything under `/etc/swanctl` are left
|
||||||
|
alone.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd debian-package
|
||||||
|
./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The build runs in a container and writes the package to `debian-package/out/`.
|
||||||
|
`./publish.sh` uploads it to the configured repository.
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
#cloud-config
|
|
||||||
|
|
||||||
apt:
|
|
||||||
sources:
|
|
||||||
cloud-router:
|
|
||||||
source: "deb [signed-by=/etc/apt/keyrings/cloud-router.gpg] ${repo_url} ${ubuntu_codename} main"
|
|
||||||
key: |
|
|
||||||
${indent(8, trimspace(repo_gpg_key))}
|
|
||||||
|
|
||||||
debconf_selections: |
|
|
||||||
cloud-router cloud-router/local_addrs string ${local_addrs}
|
|
||||||
cloud-router cloud-router/local_fqdn string ${fqdn}
|
|
||||||
cloud-router cloud-router/local_id_mode select ${local_id_mode}
|
|
||||||
cloud-router cloud-router/local_cidrs string ${local_cidrs}
|
|
||||||
cloud-router cloud-router/remote_addrs string ${remote_addrs}
|
|
||||||
cloud-router cloud-router/remote_id string ${remote_id}
|
|
||||||
cloud-router cloud-router/psk password ${psk}
|
|
||||||
cloud-router cloud-router/remote_cidrs string ${remote_cidrs}
|
|
||||||
cloud-router cloud-router/router_int_gateway_ip string ${router_int_gateway_ip}
|
|
||||||
cloud-router cloud-router/p2s_address_pool string ${p2s_address_pool}
|
|
||||||
cloud-router cloud-router/wg_enabled boolean ${wg_enabled}
|
|
||||||
cloud-router cloud-router/wg_address string ${wg_address}
|
|
||||||
cloud-router cloud-router/wg_listen_port string ${wg_listen_port}
|
|
||||||
|
|
||||||
package_update: true
|
|
||||||
|
|
||||||
packages:
|
|
||||||
- cloud-router
|
|
||||||
@@ -8,12 +8,15 @@ RUN apt-get update \
|
|||||||
build-essential \
|
build-essential \
|
||||||
dpkg-dev \
|
dpkg-dev \
|
||||||
debhelper \
|
debhelper \
|
||||||
|
lintian \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY . src/
|
COPY . src/
|
||||||
RUN cd src && dpkg-buildpackage -us -uc -b
|
RUN cd src && dpkg-buildpackage -us -uc -b
|
||||||
|
|
||||||
|
RUN lintian --no-tag-display-limit /build/*.changes || true
|
||||||
|
|
||||||
RUN mkdir /out \
|
RUN mkdir /out \
|
||||||
&& find /build -maxdepth 1 \( -name '*.deb' -o -name '*.buildinfo' -o -name '*.changes' \) \
|
&& find /build -maxdepth 1 \( -name '*.deb' -o -name '*.buildinfo' -o -name '*.changes' \) \
|
||||||
-exec install -o "$HOST_UID" -g "$HOST_GID" -m 0644 {} /out/ \;
|
-exec install -o "$HOST_UID" -g "$HOST_GID" -m 0644 {} /out/ \;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ set -e
|
|||||||
|
|
||||||
mkdir -p out
|
mkdir -p out
|
||||||
|
|
||||||
container build --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -t cloud-router-builder "$@" .
|
container build --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -t vpn-router-builder "$@" .
|
||||||
container run --rm -v "$(pwd)/out:/mnt" cloud-router-builder sh -c 'cp -p /out/* /mnt/'
|
container run --rm -v "$(pwd)/out:/mnt" vpn-router-builder sh -c 'cp -p /out/* /mnt/'
|
||||||
|
|
||||||
echo "Build artifacts written to out/"
|
echo "Build artifacts written to out/"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
cloud-router (1.0.0-1) unstable; urgency=medium
|
vpn-router (1.0.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
* Initial release.
|
* Initial release.
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,63 @@
|
|||||||
set -e
|
set -e
|
||||||
. /usr/share/debconf/confmodule
|
. /usr/share/debconf/confmodule
|
||||||
|
|
||||||
db_input high cloud-router/local_addrs || true
|
# Every question has a default, so a non-interactive install with nothing
|
||||||
db_input high cloud-router/local_fqdn || true
|
# preseeded completes without prompting. The defaults are platform "none" and
|
||||||
db_input high cloud-router/local_id_mode || true
|
# mode "manual", which together mean: install the files, configure nothing.
|
||||||
db_input high cloud-router/local_cidrs || true
|
|
||||||
db_input high cloud-router/remote_addrs || true
|
db_input high vpn-router/platform || true
|
||||||
db_input high cloud-router/remote_id || true
|
|
||||||
db_input high cloud-router/psk || true
|
|
||||||
db_input high cloud-router/remote_cidrs || true
|
|
||||||
db_input high cloud-router/router_int_gateway_ip || true
|
|
||||||
db_input high cloud-router/p2s_address_pool || true
|
|
||||||
db_input high cloud-router/wg_enabled || true
|
|
||||||
db_go || true
|
db_go || true
|
||||||
|
|
||||||
db_get cloud-router/wg_enabled
|
db_get vpn-router/platform
|
||||||
|
if [ "$RET" = "none" ]; then
|
||||||
|
# Configuration is deferred. Asking anything else would collect answers
|
||||||
|
# that nothing is going to apply.
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
db_input high vpn-router/mode || true
|
||||||
|
db_go || true
|
||||||
|
|
||||||
|
# The mode says how much the operator supplies, so it decides which interface
|
||||||
|
# and address questions are worth asking.
|
||||||
|
db_get vpn-router/mode
|
||||||
|
case "$RET" in
|
||||||
|
manual)
|
||||||
|
db_input high vpn-router/external_interface || true
|
||||||
|
db_input high vpn-router/internal_interface || true
|
||||||
|
db_input high vpn-router/int_addr || true
|
||||||
|
db_input high vpn-router/int_gateway_ip || true
|
||||||
|
;;
|
||||||
|
interfaces)
|
||||||
|
db_input high vpn-router/external_interface || true
|
||||||
|
db_input high vpn-router/internal_interface || true
|
||||||
|
;;
|
||||||
|
auto)
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
db_go || true
|
||||||
|
|
||||||
|
db_input high vpn-router/local_fqdn || true
|
||||||
|
db_input high vpn-router/local_id_mode || true
|
||||||
|
db_input high vpn-router/local_cidrs || true
|
||||||
|
db_input high vpn-router/remote_addrs || true
|
||||||
|
db_input high vpn-router/remote_id || true
|
||||||
|
db_input high vpn-router/remote_cidrs || true
|
||||||
|
db_input high vpn-router/psk || true
|
||||||
|
db_input high vpn-router/p2s_enabled || true
|
||||||
|
db_input high vpn-router/wg_enabled || true
|
||||||
|
db_go || true
|
||||||
|
|
||||||
|
db_get vpn-router/p2s_enabled
|
||||||
if [ "$RET" = "true" ]; then
|
if [ "$RET" = "true" ]; then
|
||||||
db_input high cloud-router/wg_address || true
|
db_input high vpn-router/p2s_address_pool || true
|
||||||
db_input high cloud-router/wg_listen_port || true
|
db_input high vpn-router/p2s_ca_name || true
|
||||||
|
db_go || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
db_get vpn-router/wg_enabled
|
||||||
|
if [ "$RET" = "true" ]; then
|
||||||
|
db_input high vpn-router/wg_address || true
|
||||||
|
db_input high vpn-router/wg_listen_port || true
|
||||||
db_go || true
|
db_go || true
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Source: cloud-router
|
Source: vpn-router
|
||||||
Section: net
|
Section: net
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Maintainer: Sławomir Koszewski <slawek@koszewscy.waw.pl>
|
Maintainer: Sławomir Koszewski <slawek@koszewscy.waw.pl>
|
||||||
@@ -6,7 +6,7 @@ Build-Depends: debhelper-compat (= 13)
|
|||||||
Standards-Version: 4.6.2
|
Standards-Version: 4.6.2
|
||||||
Rules-Requires-Root: no
|
Rules-Requires-Root: no
|
||||||
|
|
||||||
Package: cloud-router
|
Package: vpn-router
|
||||||
Architecture: all
|
Architecture: all
|
||||||
Depends: ${misc:Depends},
|
Depends: ${misc:Depends},
|
||||||
strongswan-swanctl,
|
strongswan-swanctl,
|
||||||
@@ -16,14 +16,25 @@ Depends: ${misc:Depends},
|
|||||||
wireguard-tools,
|
wireguard-tools,
|
||||||
ufw,
|
ufw,
|
||||||
debconf,
|
debconf,
|
||||||
|
ucf,
|
||||||
openssl,
|
openssl,
|
||||||
python3-jinja2
|
python3,
|
||||||
Description: Linux cloud router with IPSec and optional WireGuard
|
python3-jinja2,
|
||||||
Configures a Linux host as a cloud router providing site-to-site IKEv2
|
iproute2,
|
||||||
IPSec (strongSwan swanctl) and road-warrior P2S VPN (EAP-TLS). WireGuard
|
systemd-resolved
|
||||||
is optionally enabled. Includes a PKI helper library (simple-ca.sh) for
|
Description: Linux VPN router with IPSec, road-warrior and WireGuard support
|
||||||
managing the road-warrior certificate authority.
|
Configures a Linux host or virtual machine as a VPN router: site-to-site
|
||||||
|
IKEv2 IPSec (strongSwan swanctl), optional road-warrior access using EAP-TLS,
|
||||||
|
optional WireGuard, and the routing, NAT, MSS clamping and firewall rules a
|
||||||
|
router needs. Nothing in the package is specific to a cloud provider.
|
||||||
.
|
.
|
||||||
Site-specific values are collected via debconf at install time and written
|
Settings live in /etc/vpn-router/vpn-router.conf, which is created on first
|
||||||
to /etc/default/cloud-router. A one-shot systemd service (cloud-router-setup)
|
install and owned by the administrator afterwards. Editing it and restarting
|
||||||
applies UFW rules and WireGuard keys on first boot.
|
vpn-router-setup applies the change; the same service reconciles the system
|
||||||
|
at every boot. It is built on two inputs, the external and internal interface
|
||||||
|
names; the addresses on them are read from the system rather than configured
|
||||||
|
again, and nothing is auto-detected.
|
||||||
|
.
|
||||||
|
Platform-specific additions are provided by modules under
|
||||||
|
/usr/lib/vpn-router, selected explicitly by the platform setting. Modules for
|
||||||
|
Azure and GCP are included.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||||
Upstream-Name: cloud-router
|
Upstream-Name: vpn-router
|
||||||
Upstream-Contact: Sławomir Koszewski <slawek@koszewscy.waw.pl>
|
Upstream-Contact: Sławomir Koszewski <slawek@koszewscy.waw.pl>
|
||||||
|
|
||||||
Files: *
|
Files: *
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
etc/cloud-router
|
etc/vpn-router
|
||||||
etc/cloud-router/pki
|
usr/lib/vpn-router
|
||||||
etc/wireguard
|
usr/lib/vpn-router/vpnrouter_platforms
|
||||||
etc/swanctl/conf.d
|
usr/share/vpn-router/templates
|
||||||
etc/swanctl/x509ca
|
|
||||||
etc/swanctl/x509
|
|
||||||
etc/swanctl/private
|
|
||||||
etc/systemd/resolved.conf.d
|
|
||||||
usr/lib/cloud-router
|
|
||||||
usr/share/cloud-router/templates
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
src/etc/sysctl.d/99-cloud-router.conf etc/sysctl.d/
|
src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/
|
||||||
src/opt/cloud-router/bin/simple-ca opt/cloud-router/bin/
|
src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/
|
||||||
src/usr/lib/cloud-router/configure usr/lib/cloud-router/
|
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
|
||||||
src/usr/share/cloud-router/templates/* usr/share/cloud-router/templates/
|
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
|
||||||
|
src/usr/lib/vpn-router/setup usr/lib/vpn-router/
|
||||||
|
src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/
|
||||||
|
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
|
||||||
|
src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/
|
||||||
|
src/usr/share/doc/vpn-router/vpn-router.conf.example usr/share/doc/vpn-router/
|
||||||
|
|||||||
@@ -4,47 +4,61 @@ set -e
|
|||||||
|
|
||||||
case "$1" in
|
case "$1" in
|
||||||
configure)
|
configure)
|
||||||
# ── Read debconf answers ──────────────────────────────────────────────
|
# --- Read the installer's answers ---
|
||||||
db_get cloud-router/local_addrs; CLOUD_ROUTER_LOCAL_ADDRS="$RET"
|
db_get vpn-router/platform; VPN_ROUTER_PLATFORM="$RET"
|
||||||
db_get cloud-router/local_fqdn; CLOUD_ROUTER_LOCAL_FQDN="$RET"
|
db_get vpn-router/mode; VPN_ROUTER_MODE="$RET"
|
||||||
db_get cloud-router/local_id_mode; CLOUD_ROUTER_LOCAL_ID_MODE="$RET"
|
db_get vpn-router/int_addr; VPN_ROUTER_INT_ADDR="$RET"
|
||||||
db_get cloud-router/local_cidrs; CLOUD_ROUTER_LOCAL_CIDRS="$RET"
|
db_get vpn-router/external_interface; VPN_ROUTER_EXTERNAL_INTERFACE="$RET"
|
||||||
db_get cloud-router/remote_addrs; CLOUD_ROUTER_REMOTE_ADDRS="$RET"
|
db_get vpn-router/internal_interface; VPN_ROUTER_INTERNAL_INTERFACE="$RET"
|
||||||
db_get cloud-router/remote_id; CLOUD_ROUTER_REMOTE_ID="$RET"
|
db_get vpn-router/local_fqdn; VPN_ROUTER_LOCAL_FQDN="$RET"
|
||||||
db_get cloud-router/psk; CLOUD_ROUTER_PSK="$RET"
|
db_get vpn-router/local_id_mode; VPN_ROUTER_LOCAL_ID_MODE="$RET"
|
||||||
db_get cloud-router/remote_cidrs; CLOUD_ROUTER_REMOTE_CIDRS="$RET"
|
db_get vpn-router/local_cidrs; VPN_ROUTER_LOCAL_CIDRS="$RET"
|
||||||
db_get cloud-router/router_int_gateway_ip; CLOUD_ROUTER_ROUTER_INT_GATEWAY_IP="$RET"
|
db_get vpn-router/int_gateway_ip; VPN_ROUTER_INT_GATEWAY_IP="$RET"
|
||||||
db_get cloud-router/p2s_address_pool; CLOUD_ROUTER_P2S_ADDRESS_POOL="$RET"
|
db_get vpn-router/remote_addrs; VPN_ROUTER_REMOTE_ADDRS="$RET"
|
||||||
db_get cloud-router/wg_enabled; CLOUD_ROUTER_WG_ENABLED="$RET"
|
db_get vpn-router/remote_id; VPN_ROUTER_REMOTE_ID="$RET"
|
||||||
db_get cloud-router/wg_address; CLOUD_ROUTER_WG_ADDRESS="$RET"
|
db_get vpn-router/remote_cidrs; VPN_ROUTER_REMOTE_CIDRS="$RET"
|
||||||
db_get cloud-router/wg_listen_port; CLOUD_ROUTER_WG_LISTEN_PORT="$RET"
|
db_get vpn-router/psk; VPN_ROUTER_PSK="$RET"
|
||||||
|
db_get vpn-router/p2s_enabled; VPN_ROUTER_P2S_ENABLED="$RET"
|
||||||
|
db_get vpn-router/p2s_address_pool; VPN_ROUTER_P2S_ADDRESS_POOL="$RET"
|
||||||
|
db_get vpn-router/p2s_ca_name; VPN_ROUTER_P2S_CA_NAME="$RET"
|
||||||
|
db_get vpn-router/wg_enabled; VPN_ROUTER_WG_ENABLED="$RET"
|
||||||
|
db_get vpn-router/wg_address; VPN_ROUTER_WG_ADDRESS="$RET"
|
||||||
|
db_get vpn-router/wg_listen_port; VPN_ROUTER_WG_LISTEN_PORT="$RET"
|
||||||
|
|
||||||
# ── Render configuration files via Jinja2 templates ─────────────────
|
export VPN_ROUTER_PLATFORM VPN_ROUTER_MODE VPN_ROUTER_INT_ADDR \
|
||||||
export CLOUD_ROUTER_LOCAL_ADDRS CLOUD_ROUTER_LOCAL_FQDN \
|
VPN_ROUTER_EXTERNAL_INTERFACE \
|
||||||
CLOUD_ROUTER_LOCAL_ID_MODE CLOUD_ROUTER_LOCAL_CIDRS \
|
VPN_ROUTER_INTERNAL_INTERFACE VPN_ROUTER_LOCAL_FQDN \
|
||||||
CLOUD_ROUTER_REMOTE_ADDRS CLOUD_ROUTER_REMOTE_ID \
|
VPN_ROUTER_LOCAL_ID_MODE VPN_ROUTER_LOCAL_CIDRS \
|
||||||
CLOUD_ROUTER_PSK CLOUD_ROUTER_REMOTE_CIDRS \
|
VPN_ROUTER_INT_GATEWAY_IP \
|
||||||
CLOUD_ROUTER_ROUTER_INT_GATEWAY_IP CLOUD_ROUTER_P2S_ADDRESS_POOL \
|
VPN_ROUTER_REMOTE_ADDRS VPN_ROUTER_REMOTE_ID \
|
||||||
CLOUD_ROUTER_WG_ENABLED CLOUD_ROUTER_WG_ADDRESS \
|
VPN_ROUTER_REMOTE_CIDRS VPN_ROUTER_PSK \
|
||||||
CLOUD_ROUTER_WG_LISTEN_PORT
|
VPN_ROUTER_P2S_ENABLED VPN_ROUTER_P2S_ADDRESS_POOL \
|
||||||
|
VPN_ROUTER_P2S_CA_NAME VPN_ROUTER_WG_ENABLED \
|
||||||
|
VPN_ROUTER_WG_ADDRESS VPN_ROUTER_WG_LISTEN_PORT
|
||||||
|
|
||||||
/usr/lib/cloud-router/configure
|
# --- Hand a candidate configuration to ucf ---
|
||||||
|
# ucf compares it against the file in /etc and decides what to do about
|
||||||
|
# local changes, prompting through debconf only for a real conflict.
|
||||||
|
# This is what makes dpkg-reconfigure apply without destroying edits.
|
||||||
|
CANDIDATE="$(mktemp)"
|
||||||
|
/usr/lib/vpn-router/generate-config "$CANDIDATE"
|
||||||
|
ucf --three-way --debconf-ok "$CANDIDATE" /etc/vpn-router/vpn-router.conf
|
||||||
|
ucfr vpn-router /etc/vpn-router/vpn-router.conf
|
||||||
|
chmod 0600 /etc/vpn-router/vpn-router.conf
|
||||||
|
rm -f "$CANDIDATE"
|
||||||
|
|
||||||
db_set cloud-router/psk ""
|
# The key now lives in the configuration file; do not keep a copy. An
|
||||||
|
# empty answer means "leave alone" next time, so clearing it here does
|
||||||
|
# not blank the key on the next dpkg-reconfigure.
|
||||||
|
db_set vpn-router/psk ""
|
||||||
|
|
||||||
# ── Apply system settings ─────────────────────────────────────────────
|
# Apply the sysctl drop-in shipped by this package so it takes effect
|
||||||
sysctl --system
|
# without waiting for a reboot.
|
||||||
netplan apply
|
sysctl --system >/dev/null
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl restart systemd-resolved
|
|
||||||
|
|
||||||
# ── UFW: ensure SSH is allowed then enable ────────────────────────────
|
# The firewall is not touched here. Enabling it, allowing SSH and
|
||||||
ufw allow 22/tcp
|
# setting the forward policy all belong to vpn-router-setup, which does
|
||||||
ufw --force enable
|
# them only once the configuration says this host is a router.
|
||||||
ufw reload
|
|
||||||
|
|
||||||
# ── strongSwan ────────────────────────────────────────────────────────
|
|
||||||
systemctl enable --now strongswan
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BEFORE_RULES=/etc/ufw/before.rules
|
||||||
|
|
||||||
|
# Remove the rule blocks this package inserted, together with the single blank
|
||||||
|
# line that precedes each one, so before.rules returns to its original content.
|
||||||
|
strip_ufw_blocks() {
|
||||||
|
[ -f "$BEFORE_RULES" ] || return 0
|
||||||
|
grep -q '^# ROUTER MSS RULES START$\|^# IPSEC RULES START$' "$BEFORE_RULES" || return 0
|
||||||
|
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
awk '
|
||||||
|
/^# (IPSEC|WIREGUARD|P2S DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
|
||||||
|
skip = 1; pending = 0; next
|
||||||
|
}
|
||||||
|
/^# (IPSEC|WIREGUARD|P2S DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
|
||||||
|
skip = 0; next
|
||||||
|
}
|
||||||
|
skip { next }
|
||||||
|
/^$/ { pending++; next }
|
||||||
|
{
|
||||||
|
while (pending > 0) { print ""; pending-- }
|
||||||
|
print
|
||||||
|
}
|
||||||
|
END { while (pending > 0) { print ""; pending-- } }
|
||||||
|
' "$BEFORE_RULES" > "$tmp"
|
||||||
|
|
||||||
|
cat "$tmp" > "$BEFORE_RULES"
|
||||||
|
rm -f "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
purge)
|
||||||
|
strip_ufw_blocks
|
||||||
|
|
||||||
|
# Let ucf forget the file before it is removed, or a reinstall finds a
|
||||||
|
# stale hash and declines to lay the file down again.
|
||||||
|
if command -v ucf >/dev/null 2>&1; then
|
||||||
|
ucf --purge /etc/vpn-router/vpn-router.conf
|
||||||
|
fi
|
||||||
|
if command -v ucfr >/dev/null 2>&1; then
|
||||||
|
ucfr --purge vpn-router /etc/vpn-router/vpn-router.conf
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f /etc/swanctl/conf.d/remote-site.conf \
|
||||||
|
/etc/swanctl/conf.d/road-warrior.conf \
|
||||||
|
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \
|
||||||
|
/etc/wireguard/wg0.conf \
|
||||||
|
/etc/vpn-router/vpn-router.conf
|
||||||
|
|
||||||
|
# Key material is not package state: /etc/vpn-router/pki, the WireGuard
|
||||||
|
# key pair and everything under /etc/swanctl are left in place.
|
||||||
|
rmdir --ignore-fail-on-non-empty /etc/vpn-router 2>/dev/null || true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
#DEBHELPER#
|
||||||
@@ -3,7 +3,23 @@ set -e
|
|||||||
|
|
||||||
case "$1" in
|
case "$1" in
|
||||||
remove|deconfigure)
|
remove|deconfigure)
|
||||||
systemctl disable --now strongswan || true
|
# Stop offering the tunnels, but leave strongSwan itself enabled: it is
|
||||||
|
# a shared service and may be in use by something other than this
|
||||||
|
# package. The connection files are removed on purge.
|
||||||
|
if [ -d /run/systemd/system ]; then
|
||||||
|
swanctl --terminate --ike remote-site >/dev/null 2>&1 || true
|
||||||
|
swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# This package enabled wg-quick@wg0, so it takes it down again. Use the
|
||||||
|
# debhelper wrappers rather than systemctl, so the script behaves on a
|
||||||
|
# machine without systemd.
|
||||||
|
if command -v deb-systemd-invoke >/dev/null 2>&1; then
|
||||||
|
deb-systemd-invoke stop wg-quick@wg0.service >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
if command -v deb-systemd-helper >/dev/null 2>&1; then
|
||||||
|
deb-systemd-helper disable wg-quick@wg0.service >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
#!/usr/bin/make -f
|
#!/usr/bin/make -f
|
||||||
%:
|
%:
|
||||||
dh $@
|
dh $@
|
||||||
|
|
||||||
|
override_dh_installsystemd:
|
||||||
|
dh_installsystemd --name=vpn-router-setup
|
||||||
|
|||||||
+135
-56
@@ -1,80 +1,159 @@
|
|||||||
Template: cloud-router/local_addrs
|
Template: vpn-router/platform
|
||||||
Type: string
|
Type: select
|
||||||
Description: Local WAN IP address(es)
|
Choices: none, generic, azure, gcp
|
||||||
Comma-separated list of local WAN IP addresses that strongSwan binds on
|
Default: none
|
||||||
for the site-to-site and road-warrior tunnels (e.g. 10.1.2.3).
|
Description: Platform:
|
||||||
|
Which platform this router runs on. The choice loads platform-specific
|
||||||
|
additions, such as the provider DNS resolver for road-warrior clients.
|
||||||
|
.
|
||||||
|
none defers configuration entirely: the package installs its files and
|
||||||
|
changes nothing on the machine. Choose it when the router will be configured
|
||||||
|
later by hand or by a configuration-management tool, then set the platform in
|
||||||
|
/etc/vpn-router/vpn-router.conf when you are ready.
|
||||||
|
.
|
||||||
|
generic configures the router with no platform-specific additions.
|
||||||
|
|
||||||
Template: cloud-router/local_fqdn
|
Template: vpn-router/mode
|
||||||
Type: string
|
Type: select
|
||||||
Description: Local router FQDN
|
Choices: manual, interfaces, auto
|
||||||
Fully-qualified domain name of this router (e.g. router.example.com).
|
Default: manual
|
||||||
Used as the road-warrior server identity and certificate CN.
|
Description: Network configuration source:
|
||||||
|
How much of the network configuration you are supplying, and therefore how
|
||||||
|
much the package works out for itself:
|
||||||
|
.
|
||||||
|
manual - you give the interface names, the internal address and the internal
|
||||||
|
gateway. Nothing is detected.
|
||||||
|
.
|
||||||
|
interfaces - you give the two interface names. The internal address and
|
||||||
|
gateway are read from them.
|
||||||
|
.
|
||||||
|
auto - you give nothing. The interfaces are identified from the routing
|
||||||
|
table and their addresses read from the system. Convenient, but it can pick
|
||||||
|
the wrong interface and configure the machine incorrectly.
|
||||||
|
|
||||||
Template: cloud-router/local_id_mode
|
Template: vpn-router/external_interface
|
||||||
|
Type: string
|
||||||
|
Default:
|
||||||
|
Description: External network interface:
|
||||||
|
Name of the interface facing the untrusted network, for example eth0 or
|
||||||
|
ens4. The addresses strongSwan binds on are read from it, so they are never
|
||||||
|
configured separately.
|
||||||
|
|
||||||
|
Template: vpn-router/internal_interface
|
||||||
|
Type: string
|
||||||
|
Default:
|
||||||
|
Description: Internal network interface:
|
||||||
|
Name of the interface facing the protected network, for example eth1 or
|
||||||
|
ens5. Routes for the local subnets are applied to it.
|
||||||
|
|
||||||
|
Template: vpn-router/int_addr
|
||||||
|
Type: string
|
||||||
|
Default:
|
||||||
|
Description: Internal interface address:
|
||||||
|
This host's own address on the internal network. Asked only in manual mode;
|
||||||
|
in the other modes it is read from the internal interface.
|
||||||
|
|
||||||
|
Template: vpn-router/local_fqdn
|
||||||
|
Type: string
|
||||||
|
Default:
|
||||||
|
Description: Local router FQDN:
|
||||||
|
Fully-qualified domain name of this router (for example
|
||||||
|
router.example.com). Used as the road-warrior server identity and
|
||||||
|
certificate common name.
|
||||||
|
|
||||||
|
Template: vpn-router/local_id_mode
|
||||||
Type: select
|
Type: select
|
||||||
Choices: fqdn, public_ip, internal_ip
|
Choices: fqdn, public_ip, internal_ip
|
||||||
Default: fqdn
|
Default: fqdn
|
||||||
Description: IKE local identity mode
|
Description: IKE local identity mode:
|
||||||
How to derive the IKE identity advertised to the remote site:
|
How to derive the IKE identity advertised to the remote site:
|
||||||
fqdn — use the FQDN (default; requires matching on remote side)
|
.
|
||||||
public_ip — resolve the public IP from DNS at first boot
|
fqdn - use the FQDN, which must match what the peer expects.
|
||||||
internal_ip — use the local WAN IP address
|
.
|
||||||
|
public_ip - resolve the FQDN and use the address it returns.
|
||||||
|
.
|
||||||
|
internal_ip - use the internal NIC address.
|
||||||
|
|
||||||
Template: cloud-router/local_cidrs
|
Template: vpn-router/local_cidrs
|
||||||
Type: string
|
Type: string
|
||||||
Description: Local subnet CIDR(s)
|
Default:
|
||||||
|
Description: Local subnet CIDR(s):
|
||||||
Comma-separated list of local subnet CIDRs to advertise into the
|
Comma-separated list of local subnet CIDRs to advertise into the
|
||||||
site-to-site tunnel (e.g. 10.0.0.0/24 or 10.0.0.0/24,10.0.1.0/24).
|
site-to-site tunnel (for example 10.0.0.0/24 or 10.0.0.0/24,10.0.1.0/24).
|
||||||
|
|
||||||
Template: cloud-router/remote_addrs
|
Template: vpn-router/int_gateway_ip
|
||||||
Type: string
|
Type: string
|
||||||
Description: Remote site WAN IP address(es)
|
Default:
|
||||||
Comma-separated list of remote site WAN IP addresses for the
|
Description: Internal network gateway address:
|
||||||
|
Address of the next-hop gateway on the internal side, used to route the
|
||||||
|
local subnets listed above. Asked only in manual mode; in the other modes it
|
||||||
|
is derived from the internal interface.
|
||||||
|
|
||||||
|
Template: vpn-router/remote_addrs
|
||||||
|
Type: string
|
||||||
|
Default:
|
||||||
|
Description: Remote site WAN IP address(es):
|
||||||
|
Comma-separated list of remote gateway addresses or FQDNs for the
|
||||||
site-to-site IPSec tunnel.
|
site-to-site IPSec tunnel.
|
||||||
|
|
||||||
Template: cloud-router/remote_id
|
Template: vpn-router/remote_id
|
||||||
Type: string
|
Type: string
|
||||||
Description: Remote site IKE identity
|
Default:
|
||||||
IKE identity of the remote peer (FQDN, without leading @).
|
Description: Remote site IKE identity:
|
||||||
|
IKE identity of the remote peer, without a leading @.
|
||||||
|
|
||||||
Template: cloud-router/psk
|
Template: vpn-router/remote_cidrs
|
||||||
|
Type: string
|
||||||
|
Default:
|
||||||
|
Description: Remote subnet CIDR(s):
|
||||||
|
Comma-separated list of remote subnet CIDRs reachable through the
|
||||||
|
site-to-site tunnel (for example 192.168.0.0/24).
|
||||||
|
|
||||||
|
Template: vpn-router/psk
|
||||||
Type: password
|
Type: password
|
||||||
Description: Pre-shared key (PSK)
|
Description: Pre-shared key (PSK):
|
||||||
Pre-shared key for the site-to-site IKEv2 tunnel. Must match the
|
Pre-shared key for the site-to-site IKEv2 tunnel. Must match the value
|
||||||
value configured on the remote peer.
|
configured on the remote peer. Stored base64-encoded in the configuration
|
||||||
|
file and cleared from the debconf database after installation.
|
||||||
|
|
||||||
Template: cloud-router/remote_cidrs
|
Template: vpn-router/p2s_enabled
|
||||||
Type: string
|
|
||||||
Description: Remote subnet CIDR(s)
|
|
||||||
Comma-separated list of remote subnet CIDRs for the site-to-site
|
|
||||||
tunnel (e.g. 192.168.0.0/24).
|
|
||||||
|
|
||||||
Template: cloud-router/router_int_gateway_ip
|
|
||||||
Type: string
|
|
||||||
Description: Internal network gateway IP
|
|
||||||
IP address of the next-hop gateway on the internal NIC (eth1).
|
|
||||||
Used in the netplan route for the local subnet.
|
|
||||||
|
|
||||||
Template: cloud-router/p2s_address_pool
|
|
||||||
Type: string
|
|
||||||
Description: Road-warrior address pool
|
|
||||||
CIDR block assigned to road-warrior VPN clients (e.g. 172.16.0.0/24).
|
|
||||||
|
|
||||||
Template: cloud-router/wg_enabled
|
|
||||||
Type: boolean
|
Type: boolean
|
||||||
Default: false
|
Default: false
|
||||||
Description: Enable WireGuard VPN?
|
Description: Enable road-warrior (P2S) access?
|
||||||
If true, WireGuard is configured on wg0 and its UFW rules are installed.
|
If enabled, this router accepts IKEv2 EAP-TLS connections from individual
|
||||||
|
clients. Certificates are taken from /etc/vpn-router/pki, and a local
|
||||||
|
certificate authority is created there if that directory is empty.
|
||||||
|
|
||||||
Template: cloud-router/wg_address
|
Template: vpn-router/p2s_address_pool
|
||||||
Type: string
|
Type: string
|
||||||
Default: 10.0.1.1/24
|
Default:
|
||||||
Description: WireGuard interface address
|
Description: Road-warrior address pool:
|
||||||
IP address and prefix length for the wg0 interface (e.g. 10.0.1.1/24).
|
CIDR block assigned to road-warrior clients (for example 172.16.0.0/24).
|
||||||
Only used when WireGuard is enabled.
|
|
||||||
|
|
||||||
Template: cloud-router/wg_listen_port
|
Template: vpn-router/p2s_ca_name
|
||||||
|
Type: string
|
||||||
|
Default: VPN Router CA
|
||||||
|
Description: Road-warrior CA name:
|
||||||
|
Common name for the certificate authority created in /etc/vpn-router/pki
|
||||||
|
when that directory is empty. Ignored when certificates are supplied.
|
||||||
|
|
||||||
|
Template: vpn-router/wg_enabled
|
||||||
|
Type: boolean
|
||||||
|
Default: false
|
||||||
|
Description: Enable WireGuard?
|
||||||
|
If enabled, WireGuard is configured on wg0, a key pair is generated, and
|
||||||
|
the matching firewall rule is installed. Peers are added by editing
|
||||||
|
/etc/wireguard/wg0.conf.
|
||||||
|
|
||||||
|
Template: vpn-router/wg_address
|
||||||
|
Type: string
|
||||||
|
Default:
|
||||||
|
Description: WireGuard interface address:
|
||||||
|
Address and prefix length for the wg0 interface (for example
|
||||||
|
192.168.200.1/24).
|
||||||
|
|
||||||
|
Template: vpn-router/wg_listen_port
|
||||||
Type: string
|
Type: string
|
||||||
Default: 51820
|
Default: 51820
|
||||||
Description: WireGuard listen port
|
Description: WireGuard listen port:
|
||||||
UDP port that WireGuard listens on. Only used when WireGuard is enabled.
|
UDP port that WireGuard listens on.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=VPN Router Setup
|
||||||
|
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/lib/vpn-router/setup
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Regular → Executable
+18
-1
@@ -1,3 +1,20 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
changelog_field() {
|
||||||
|
container run --rm -v "$(pwd):/mnt" vpn-router-builder \
|
||||||
|
dpkg-parsechangelog --show-field "$1" -l /mnt/debian/changelog
|
||||||
|
}
|
||||||
|
|
||||||
|
VERSION="$(changelog_field Version)"
|
||||||
|
PACKAGE="$(changelog_field Source)"
|
||||||
|
DEB="out/${PACKAGE}_${VERSION}_all.deb"
|
||||||
|
|
||||||
|
if [ ! -f "$DEB" ]; then
|
||||||
|
printf 'ERROR: %s does not exist. Run ./build.sh first.\n' "$DEB" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
curl -v --user "slawek:$(cat ~/.gitea-packages-token)" \
|
curl -v --user "slawek:$(cat ~/.gitea-packages-token)" \
|
||||||
--upload-file out/cloud-router_1.0.0-1_all.deb \
|
--upload-file "$DEB" \
|
||||||
"https://gitea.koszewscy.waw.pl/api/packages/slawek/debian/pool/noble/main/upload"
|
"https://gitea.koszewscy.waw.pl/api/packages/slawek/debian/pool/noble/main/upload"
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# MIT License
|
|
||||||
#
|
|
||||||
# Copyright (c) 2026 Sławomir Koszewski
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
"""simple-ca — minimal CA for cloud-router IKEv2 PKI.
|
|
||||||
|
|
||||||
Directory layout:
|
|
||||||
/etc/swanctl/x509ca/ca.pem CA certificate (strongSwan trust anchor)
|
|
||||||
/etc/swanctl/private/ca.key CA private key
|
|
||||||
/etc/swanctl/x509/server.pem server certificate
|
|
||||||
/etc/swanctl/private/server.key server private key
|
|
||||||
/etc/cloud-router/pki/{name}_cert.pem user/road-warrior certificate
|
|
||||||
/etc/cloud-router/pki/{name}_key.pem user/road-warrior private key
|
|
||||||
/etc/cloud-router/pki/{name}.pfx PKCS#12 bundle for client distribution
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
CA_CERT = Path('/etc/swanctl/x509ca/ca.pem')
|
|
||||||
CA_KEY = Path('/etc/swanctl/private/ca.key')
|
|
||||||
SERVER_CERT = Path('/etc/swanctl/x509/server.pem')
|
|
||||||
SERVER_KEY = Path('/etc/swanctl/private/server.key')
|
|
||||||
PKI_DIR = Path('/etc/cloud-router/pki')
|
|
||||||
|
|
||||||
|
|
||||||
def _run(*args, stdin=None):
|
|
||||||
result = subprocess.run(list(args), input=stdin, capture_output=True)
|
|
||||||
if result.returncode != 0:
|
|
||||||
sys.stderr.buffer.write(result.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
return result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def _san_prefix(s):
|
|
||||||
if '@' in s:
|
|
||||||
return f'email:{s}'
|
|
||||||
parts = s.split('.')
|
|
||||||
if len(parts) == 4 and all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
|
|
||||||
return f'IP:{s}'
|
|
||||||
return f'DNS:{s}'
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_make_ca(args):
|
|
||||||
if CA_CERT.exists() and CA_KEY.exists():
|
|
||||||
print('Root CA already exists, skipping.')
|
|
||||||
return
|
|
||||||
print('Generating root CA certificate and key...')
|
|
||||||
_run(
|
|
||||||
'openssl', 'req',
|
|
||||||
'-x509',
|
|
||||||
'-newkey', 'rsa:4096',
|
|
||||||
'-keyout', str(CA_KEY),
|
|
||||||
'-out', str(CA_CERT),
|
|
||||||
'-days', str(args.days),
|
|
||||||
'-noenc',
|
|
||||||
'-subj', f'/CN={args.name}',
|
|
||||||
'-text',
|
|
||||||
'-addext', 'basicConstraints=critical,CA:TRUE,pathlen:1',
|
|
||||||
'-addext', 'keyUsage=critical,keyCertSign,cRLSign',
|
|
||||||
)
|
|
||||||
CA_KEY.chmod(0o600)
|
|
||||||
print(f'Root CA created.')
|
|
||||||
print(f' Certificate : {CA_CERT}')
|
|
||||||
print(f' Private key : {CA_KEY}')
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_make_cert(args):
|
|
||||||
name = args.subject.split('.')[0]
|
|
||||||
if args.type == 'server':
|
|
||||||
cert_path = SERVER_CERT
|
|
||||||
key_path = SERVER_KEY
|
|
||||||
else:
|
|
||||||
cert_path = PKI_DIR / f'{name}_cert.pem'
|
|
||||||
key_path = PKI_DIR / f'{name}_key.pem'
|
|
||||||
|
|
||||||
if cert_path.exists() and key_path.exists():
|
|
||||||
print(f'Certificate already exists at {cert_path}, skipping.')
|
|
||||||
return
|
|
||||||
|
|
||||||
if not CA_CERT.exists() or not CA_KEY.exists():
|
|
||||||
print('ERROR: Root CA not found. Run: simple-ca make-ca <name>', file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
req_args = [
|
|
||||||
'openssl', 'req',
|
|
||||||
'-newkey', 'rsa:4096',
|
|
||||||
'-keyout', str(key_path),
|
|
||||||
'-noenc',
|
|
||||||
'-subj', f'/CN={args.subject}',
|
|
||||||
'-addext', 'basicConstraints=critical,CA:FALSE',
|
|
||||||
]
|
|
||||||
|
|
||||||
if args.type == 'server':
|
|
||||||
sans = [f'DNS:{args.subject}'] + [_san_prefix(s) for s in args.san]
|
|
||||||
req_args += [
|
|
||||||
'-addext', 'keyUsage=critical,digitalSignature,keyEncipherment',
|
|
||||||
'-addext', 'extendedKeyUsage=serverAuth,clientAuth',
|
|
||||||
'-addext', f'subjectAltName={",".join(sans)}',
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
sans = [_san_prefix(s) for s in args.san]
|
|
||||||
req_args += [
|
|
||||||
'-addext', 'keyUsage=critical,digitalSignature,nonRepudiation',
|
|
||||||
'-addext', 'extendedKeyUsage=clientAuth,emailProtection',
|
|
||||||
]
|
|
||||||
if sans:
|
|
||||||
req_args += ['-addext', f'subjectAltName={",".join(sans)}']
|
|
||||||
|
|
||||||
x509_args = [
|
|
||||||
'openssl', 'x509',
|
|
||||||
'-req',
|
|
||||||
'-CA', str(CA_CERT),
|
|
||||||
'-CAkey', str(CA_KEY),
|
|
||||||
'-copy_extensions', 'copyall',
|
|
||||||
'-days', str(args.days),
|
|
||||||
'-text',
|
|
||||||
'-out', str(cert_path),
|
|
||||||
]
|
|
||||||
|
|
||||||
csr = _run(*req_args)
|
|
||||||
_run(*x509_args, stdin=csr)
|
|
||||||
key_path.chmod(0o600)
|
|
||||||
print('Certificate created.')
|
|
||||||
print(f' Certificate : {cert_path}')
|
|
||||||
print(f' Private key : {key_path}')
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_make_pfx(args):
|
|
||||||
name = args.name
|
|
||||||
cert_path = PKI_DIR / f'{name}_cert.pem'
|
|
||||||
key_path = PKI_DIR / f'{name}_key.pem'
|
|
||||||
pfx_path = PKI_DIR / f'{name}.pfx'
|
|
||||||
|
|
||||||
if not cert_path.exists() or not key_path.exists():
|
|
||||||
print(f'ERROR: Certificate or key not found for {name!r} in {PKI_DIR}.', file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if pfx_path.exists():
|
|
||||||
print(f'ERROR: {pfx_path} already exists.', file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
password = args.password or 'changeit'
|
|
||||||
_run(
|
|
||||||
'openssl', 'pkcs12',
|
|
||||||
'-export',
|
|
||||||
'-out', str(pfx_path),
|
|
||||||
'-inkey', str(key_path),
|
|
||||||
'-in', str(cert_path),
|
|
||||||
'-certfile', str(CA_CERT),
|
|
||||||
'-password', f'pass:{password}',
|
|
||||||
)
|
|
||||||
print(f'PKCS#12 created: {pfx_path}')
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(
|
|
||||||
prog='simple-ca',
|
|
||||||
description='Minimal CA for cloud-router IKEv2 PKI.',
|
|
||||||
)
|
|
||||||
sub = ap.add_subparsers(dest='command', required=True)
|
|
||||||
|
|
||||||
p_ca = sub.add_parser('make-ca', help='Create root CA')
|
|
||||||
p_ca.add_argument('name', help='CA common name')
|
|
||||||
p_ca.add_argument('--days', type=int, default=3650, metavar='N')
|
|
||||||
p_ca.set_defaults(func=cmd_make_ca)
|
|
||||||
|
|
||||||
p_cert = sub.add_parser('make-cert', help='Issue a certificate')
|
|
||||||
p_cert.add_argument('subject', help='Subject CN (FQDN for server, username for user)')
|
|
||||||
p_cert.add_argument('san', nargs='*', help='Additional SANs: IP, DNS, or email')
|
|
||||||
p_cert.add_argument('--type', choices=['server', 'user'], default='server',
|
|
||||||
help='Certificate type (default: server)')
|
|
||||||
p_cert.add_argument('--days', type=int, default=365, metavar='N')
|
|
||||||
p_cert.set_defaults(func=cmd_make_cert)
|
|
||||||
|
|
||||||
p_pfx = sub.add_parser('make-pfx', help='Export PKCS#12 bundle for a user certificate')
|
|
||||||
p_pfx.add_argument('name', help='Certificate name (without extension)')
|
|
||||||
p_pfx.add_argument('--password', metavar='PASS',
|
|
||||||
help='Export password (default: changeit)')
|
|
||||||
p_pfx.set_defaults(func=cmd_make_pfx)
|
|
||||||
|
|
||||||
args = ap.parse_args()
|
|
||||||
args.func(args)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
-257
@@ -1,257 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
"""Render cloud-router configuration files and configure the system."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import socket
|
|
||||||
import pathlib
|
|
||||||
import subprocess
|
|
||||||
import jinja2
|
|
||||||
|
|
||||||
TEMPLATE_DIR = pathlib.Path('/usr/share/cloud-router/templates')
|
|
||||||
|
|
||||||
|
|
||||||
def _require(name):
|
|
||||||
val = os.environ.get(name)
|
|
||||||
if val is None:
|
|
||||||
print(f'ERROR: environment variable {name} is not set', file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
return val
|
|
||||||
|
|
||||||
|
|
||||||
def build_context():
|
|
||||||
local_addrs = _require('CLOUD_ROUTER_LOCAL_ADDRS')
|
|
||||||
local_fqdn = _require('CLOUD_ROUTER_LOCAL_FQDN')
|
|
||||||
local_id_mode = _require('CLOUD_ROUTER_LOCAL_ID_MODE')
|
|
||||||
local_cidrs = _require('CLOUD_ROUTER_LOCAL_CIDRS')
|
|
||||||
remote_addrs = _require('CLOUD_ROUTER_REMOTE_ADDRS')
|
|
||||||
remote_id = _require('CLOUD_ROUTER_REMOTE_ID')
|
|
||||||
psk = _require('CLOUD_ROUTER_PSK')
|
|
||||||
remote_cidrs = _require('CLOUD_ROUTER_REMOTE_CIDRS')
|
|
||||||
router_int_gateway_ip = _require('CLOUD_ROUTER_ROUTER_INT_GATEWAY_IP')
|
|
||||||
p2s_address_pool = _require('CLOUD_ROUTER_P2S_ADDRESS_POOL')
|
|
||||||
wg_enabled = _require('CLOUD_ROUTER_WG_ENABLED')
|
|
||||||
wg_address = _require('CLOUD_ROUTER_WG_ADDRESS')
|
|
||||||
wg_listen_port = _require('CLOUD_ROUTER_WG_LISTEN_PORT')
|
|
||||||
|
|
||||||
local_subnet = local_cidrs.split(',')[0].strip()
|
|
||||||
p2s_server_name = local_fqdn.split('.')[0]
|
|
||||||
|
|
||||||
if local_id_mode == 'fqdn':
|
|
||||||
local_id = f'@{local_fqdn}'
|
|
||||||
elif local_id_mode == 'public_ip':
|
|
||||||
try:
|
|
||||||
local_id = socket.getaddrinfo(local_fqdn, None, socket.AF_INET)[0][4][0]
|
|
||||||
except socket.gaierror as exc:
|
|
||||||
print(f'ERROR: cannot resolve {local_fqdn}: {exc}', file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
elif local_id_mode == 'internal_ip':
|
|
||||||
local_id = local_addrs
|
|
||||||
else:
|
|
||||||
local_id = f'@{local_fqdn}'
|
|
||||||
|
|
||||||
return {
|
|
||||||
'local_addrs': local_addrs,
|
|
||||||
'local_fqdn': local_fqdn,
|
|
||||||
'local_id_mode': local_id_mode,
|
|
||||||
'local_cidrs': local_cidrs,
|
|
||||||
'local_subnet': local_subnet,
|
|
||||||
'remote_addrs': remote_addrs,
|
|
||||||
'remote_id': remote_id,
|
|
||||||
'psk': psk,
|
|
||||||
'remote_cidrs': remote_cidrs,
|
|
||||||
'router_int_gateway_ip': router_int_gateway_ip,
|
|
||||||
'p2s_address_pool': p2s_address_pool,
|
|
||||||
'p2s_server_name': p2s_server_name,
|
|
||||||
'wg_enabled': wg_enabled,
|
|
||||||
'wg_address': wg_address,
|
|
||||||
'wg_listen_port': wg_listen_port,
|
|
||||||
'local_id': local_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def render(jinja_env, ctx, template_name, dest, mode):
|
|
||||||
content = jinja_env.get_template(template_name).render(ctx)
|
|
||||||
dest = pathlib.Path(dest)
|
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
dest.write_text(content, encoding='utf-8')
|
|
||||||
os.chmod(dest, mode)
|
|
||||||
os.chown(dest, 0, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def detect_wan_iface():
|
|
||||||
result = subprocess.run(
|
|
||||||
['ip', 'route', 'get', '1.1.1.1'],
|
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
print('ERROR: ip route get 1.1.1.1 failed', file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
tokens = result.stdout.split()
|
|
||||||
for i, tok in enumerate(tokens):
|
|
||||||
if tok == 'dev' and i + 1 < len(tokens):
|
|
||||||
return tokens[i + 1]
|
|
||||||
print('ERROR: unable to detect WAN interface', file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_wireguard(ctx):
|
|
||||||
if ctx['wg_enabled'] != 'true':
|
|
||||||
return
|
|
||||||
wg_dir = pathlib.Path('/etc/wireguard')
|
|
||||||
wg_dir.mkdir(mode=0o700, exist_ok=True)
|
|
||||||
key_file = wg_dir / 'wg0.key'
|
|
||||||
if not key_file.exists() or key_file.stat().st_size == 0:
|
|
||||||
result = subprocess.run(['wg', 'genkey'], capture_output=True, check=True)
|
|
||||||
key_file.write_bytes(result.stdout)
|
|
||||||
os.chmod(key_file, 0o600)
|
|
||||||
pub_result = subprocess.run(
|
|
||||||
['wg', 'pubkey'],
|
|
||||||
input=key_file.read_bytes(),
|
|
||||||
capture_output=True, check=True,
|
|
||||||
)
|
|
||||||
pub_file = wg_dir / 'wg0.pub'
|
|
||||||
pub_file.write_bytes(pub_result.stdout)
|
|
||||||
os.chmod(pub_file, 0o644)
|
|
||||||
|
|
||||||
|
|
||||||
def _insert_after(content, marker, block):
|
|
||||||
"""Insert block after the first line that exactly matches marker."""
|
|
||||||
lines = content.splitlines(keepends=True)
|
|
||||||
result = []
|
|
||||||
for line in lines:
|
|
||||||
result.append(line)
|
|
||||||
if line.rstrip('\n') == marker:
|
|
||||||
result.append(block)
|
|
||||||
return ''.join(result)
|
|
||||||
|
|
||||||
|
|
||||||
def _insert_after_first_commit(content, block):
|
|
||||||
"""Insert block after the first COMMIT line (end of *filter table)."""
|
|
||||||
lines = content.splitlines(keepends=True)
|
|
||||||
result = []
|
|
||||||
inserted = False
|
|
||||||
for line in lines:
|
|
||||||
result.append(line)
|
|
||||||
if not inserted and line.rstrip('\n') == 'COMMIT':
|
|
||||||
result.append(block)
|
|
||||||
inserted = True
|
|
||||||
return ''.join(result)
|
|
||||||
|
|
||||||
|
|
||||||
def _wg_block(ctx):
|
|
||||||
return (
|
|
||||||
'\n'
|
|
||||||
'# WIREGUARD RULES START\n'
|
|
||||||
f'-A ufw-before-input -p udp --dport {ctx["wg_listen_port"]} -j ACCEPT\n'
|
|
||||||
'# WIREGUARD RULES END\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_ufw(ctx, wan_iface):
|
|
||||||
# ── DEFAULT_FORWARD_POLICY ────────────────────────────────────────────────
|
|
||||||
ufw_defaults = pathlib.Path('/etc/default/ufw')
|
|
||||||
if ufw_defaults.exists():
|
|
||||||
lines = ufw_defaults.read_text().splitlines(keepends=True)
|
|
||||||
lines = [
|
|
||||||
'DEFAULT_FORWARD_POLICY="ACCEPT"\n'
|
|
||||||
if ln.startswith('DEFAULT_FORWARD_POLICY=') else ln
|
|
||||||
for ln in lines
|
|
||||||
]
|
|
||||||
ufw_defaults.write_text(''.join(lines))
|
|
||||||
|
|
||||||
# ── before.rules ─────────────────────────────────────────────────────────
|
|
||||||
before_rules = pathlib.Path('/etc/ufw/before.rules')
|
|
||||||
if not before_rules.exists():
|
|
||||||
print(f'ERROR: {before_rules} does not exist', file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
content = before_rules.read_text()
|
|
||||||
|
|
||||||
# Idempotency: handle dpkg-reconfigure re-runs
|
|
||||||
if '# IPSEC RULES START' in content:
|
|
||||||
if ctx['wg_enabled'] == 'true' and '# WIREGUARD RULES START' not in content:
|
|
||||||
content = _insert_after(content, '# P2S DNS RULES END', _wg_block(ctx))
|
|
||||||
before_rules.write_text(content)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Filter table additions (IPSEC, P2S DNS, FORWARD)
|
|
||||||
filter_block = (
|
|
||||||
'\n'
|
|
||||||
'# IPSEC RULES START\n'
|
|
||||||
'-A ufw-before-input -p udp --dport 500 -j ACCEPT\n'
|
|
||||||
'-A ufw-before-input -p udp --dport 4500 -j ACCEPT\n'
|
|
||||||
'-A ufw-before-input -p esp -j ACCEPT\n'
|
|
||||||
'-A ufw-before-input -m policy --dir in --pol ipsec -j ACCEPT\n'
|
|
||||||
'-A ufw-before-output -m policy --dir out --pol ipsec -j ACCEPT\n'
|
|
||||||
'-A ufw-before-forward -m policy --dir in --pol ipsec -j ACCEPT\n'
|
|
||||||
'-A ufw-before-forward -m policy --dir out --pol ipsec -j ACCEPT\n'
|
|
||||||
'# IPSEC RULES END\n'
|
|
||||||
'\n'
|
|
||||||
'# P2S DNS RULES START\n'
|
|
||||||
f'-A ufw-before-input -s {ctx["p2s_address_pool"]} -d {ctx["local_addrs"]} -p udp --dport 53 -j ACCEPT\n'
|
|
||||||
f'-A ufw-before-input -s {ctx["p2s_address_pool"]} -d {ctx["local_addrs"]} -p tcp --dport 53 -j ACCEPT\n'
|
|
||||||
'# P2S DNS RULES END\n'
|
|
||||||
'\n'
|
|
||||||
'# ROUTER FORWARD RULES START\n'
|
|
||||||
f'-A ufw-before-forward -s {ctx["local_subnet"]} -o {wan_iface} -j ACCEPT\n'
|
|
||||||
f'-A ufw-before-forward -d {ctx["local_subnet"]} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT\n'
|
|
||||||
'# ROUTER FORWARD RULES END\n'
|
|
||||||
)
|
|
||||||
content = _insert_after(content, '# End required lines', filter_block)
|
|
||||||
|
|
||||||
# NAT table (inserted after the filter table's COMMIT)
|
|
||||||
nat_lines = [
|
|
||||||
'\n# ROUTER NAT RULES START\n',
|
|
||||||
'*nat\n',
|
|
||||||
':POSTROUTING ACCEPT [0:0]\n',
|
|
||||||
'-F POSTROUTING\n',
|
|
||||||
]
|
|
||||||
for cidr in ctx['remote_cidrs'].split(','):
|
|
||||||
nat_lines.append(
|
|
||||||
f'-A POSTROUTING -s {ctx["local_subnet"]} -d {cidr.strip()} -j RETURN\n'
|
|
||||||
)
|
|
||||||
nat_lines.append(
|
|
||||||
f'-A POSTROUTING -s {ctx["local_subnet"]} -o {wan_iface} -j MASQUERADE\n'
|
|
||||||
)
|
|
||||||
nat_lines.append('COMMIT\n# ROUTER NAT RULES END\n')
|
|
||||||
content = _insert_after_first_commit(content, ''.join(nat_lines))
|
|
||||||
|
|
||||||
if ctx['wg_enabled'] == 'true':
|
|
||||||
content = _insert_after(content, '# P2S DNS RULES END', _wg_block(ctx))
|
|
||||||
|
|
||||||
before_rules.write_text(content)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ctx = build_context()
|
|
||||||
|
|
||||||
loader = jinja2.FileSystemLoader(str(TEMPLATE_DIR))
|
|
||||||
jinja_env = jinja2.Environment(
|
|
||||||
loader=loader,
|
|
||||||
keep_trailing_newline=True,
|
|
||||||
undefined=jinja2.StrictUndefined,
|
|
||||||
autoescape=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
render(jinja_env, ctx, 'cloud-router.default.j2',
|
|
||||||
'/etc/default/cloud-router', 0o644)
|
|
||||||
render(jinja_env, ctx, 'remote-site.conf.j2',
|
|
||||||
'/etc/swanctl/conf.d/remote-site.conf', 0o600)
|
|
||||||
render(jinja_env, ctx, 'road-warrior.conf.j2',
|
|
||||||
'/etc/swanctl/conf.d/road-warrior.conf', 0o600)
|
|
||||||
render(jinja_env, ctx, 'p2s-forwarder.conf.j2',
|
|
||||||
'/etc/systemd/resolved.conf.d/p2s-forwarder.conf', 0o644)
|
|
||||||
render(jinja_env, ctx, '90-cloud-router.yaml.j2',
|
|
||||||
'/etc/netplan/90-cloud-router.yaml', 0o600)
|
|
||||||
|
|
||||||
if ctx['wg_enabled'] == 'true':
|
|
||||||
render(jinja_env, ctx, 'wg0.conf.j2',
|
|
||||||
'/etc/wireguard/wg0.conf', 0o600)
|
|
||||||
|
|
||||||
wan_iface = detect_wan_iface()
|
|
||||||
setup_wireguard(ctx)
|
|
||||||
setup_ufw(ctx, wan_iface)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
"""Render configuration files and provision PKI from vpn-router.conf.
|
||||||
|
|
||||||
|
Run directly to regenerate them without starting, stopping or reloading
|
||||||
|
anything. This still writes to /etc/swanctl, /etc/systemd/resolved.conf.d and
|
||||||
|
/etc/wireguard, and may create the certificate authority and add it to the host
|
||||||
|
trust store; what it leaves alone is routes, firewall rules and services.
|
||||||
|
'setup' calls the same code before applying those.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||||
|
|
||||||
|
import vpnrouter
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
vpnrouter.configure()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
"""Write a candidate vpn-router.conf to the path given on the command line.
|
||||||
|
|
||||||
|
Called from postinst, which hands the result to ucf(1). ucf compares it with
|
||||||
|
/etc/vpn-router/vpn-router.conf and decides what to do about local changes, so
|
||||||
|
this script never writes to /etc and never has to worry about destroying an
|
||||||
|
administrator's edits.
|
||||||
|
|
||||||
|
The candidate is built in three layers, each overriding the one before:
|
||||||
|
|
||||||
|
1. the shipped defaults
|
||||||
|
2. the current contents of /etc/vpn-router/vpn-router.conf, if it exists
|
||||||
|
3. any debconf answer that was actually given
|
||||||
|
|
||||||
|
Layer 2 is what makes reconfiguration safe: a setting the administrator edited
|
||||||
|
by hand, and that debconf has nothing to say about, is carried through
|
||||||
|
unchanged, so ucf only sees a diff where something really changed. An empty
|
||||||
|
answer means "leave alone", which is why clearing the pre-shared key from the
|
||||||
|
debconf database after installation does not blank it on the next
|
||||||
|
dpkg-reconfigure.
|
||||||
|
|
||||||
|
Answers arrive as VPN_ROUTER_* environment variables. The pre-shared key is
|
||||||
|
stored base64-encoded so that arbitrary characters cannot collide with INI
|
||||||
|
syntax.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||||
|
|
||||||
|
import vpnrouter
|
||||||
|
|
||||||
|
# environment variable -> (section, option)
|
||||||
|
ANSWERS = {
|
||||||
|
'VPN_ROUTER_PLATFORM': ('general', 'platform'),
|
||||||
|
'VPN_ROUTER_MODE': ('general', 'mode'),
|
||||||
|
'VPN_ROUTER_INT_ADDR': ('local', 'int_addr'),
|
||||||
|
'VPN_ROUTER_EXTERNAL_INTERFACE': ('interfaces', 'external'),
|
||||||
|
'VPN_ROUTER_INTERNAL_INTERFACE': ('interfaces', 'internal'),
|
||||||
|
'VPN_ROUTER_LOCAL_FQDN': ('wan', 'local_fqdn'),
|
||||||
|
'VPN_ROUTER_LOCAL_ID_MODE': ('wan', 'local_id_mode'),
|
||||||
|
'VPN_ROUTER_LOCAL_CIDRS': ('local', 'cidrs'),
|
||||||
|
'VPN_ROUTER_INT_GATEWAY_IP': ('local', 'int_gateway_ip'),
|
||||||
|
'VPN_ROUTER_REMOTE_ADDRS': ('remote', 'addrs'),
|
||||||
|
'VPN_ROUTER_REMOTE_ID': ('remote', 'id'),
|
||||||
|
'VPN_ROUTER_REMOTE_CIDRS': ('remote', 'cidrs'),
|
||||||
|
'VPN_ROUTER_P2S_ENABLED': ('p2s', 'enabled'),
|
||||||
|
'VPN_ROUTER_P2S_ADDRESS_POOL': ('p2s', 'address_pool'),
|
||||||
|
'VPN_ROUTER_P2S_CA_NAME': ('p2s', 'ca_name'),
|
||||||
|
'VPN_ROUTER_WG_ENABLED': ('wireguard', 'enabled'),
|
||||||
|
'VPN_ROUTER_WG_ADDRESS': ('wireguard', 'address'),
|
||||||
|
'VPN_ROUTER_WG_LISTEN_PORT': ('wireguard', 'listen_port'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print('usage: generate-config OUTPUT', file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
output = sys.argv[1]
|
||||||
|
|
||||||
|
parser = vpnrouter.default_parser()
|
||||||
|
|
||||||
|
current = vpnrouter.load_config()
|
||||||
|
for section in parser.sections():
|
||||||
|
for option in parser[section]:
|
||||||
|
if current.has_option(section, option):
|
||||||
|
parser[section][option] = current.get(section, option)
|
||||||
|
|
||||||
|
for variable, (section, option) in ANSWERS.items():
|
||||||
|
if os.environ.get(variable):
|
||||||
|
parser[section][option] = os.environ[variable]
|
||||||
|
|
||||||
|
psk = os.environ.get('VPN_ROUTER_PSK', '')
|
||||||
|
if psk:
|
||||||
|
parser['remote']['psk_b64'] = base64.b64encode(
|
||||||
|
psk.encode('utf-8')).decode('ascii')
|
||||||
|
|
||||||
|
with open(output, 'w', encoding='utf-8') as handle:
|
||||||
|
parser.write(handle)
|
||||||
|
os.chmod(output, 0o600)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
Executable
+344
@@ -0,0 +1,344 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
"""Apply the configuration to the running system.
|
||||||
|
|
||||||
|
Regenerates the configuration files first, then does everything that needs
|
||||||
|
the live system: routes, DNS-derived identity, WireGuard keys, firewall rules
|
||||||
|
and service reloads. Idempotent - a second run changes nothing.
|
||||||
|
|
||||||
|
Interface names come from the configuration and are never guessed. The
|
||||||
|
firewall and NAT rules are written against them, so choosing the wrong one
|
||||||
|
would be a silent and security-relevant mistake.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||||
|
|
||||||
|
import vpnrouter
|
||||||
|
from vpnrouter import fail
|
||||||
|
|
||||||
|
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
|
||||||
|
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
|
||||||
|
WG_DIR = pathlib.Path('/etc/wireguard')
|
||||||
|
|
||||||
|
FILTER_ANCHOR = '# End required lines'
|
||||||
|
|
||||||
|
BLOCKS = (
|
||||||
|
('# IPSEC RULES START', '# IPSEC RULES END'),
|
||||||
|
('# WIREGUARD RULES START', '# WIREGUARD RULES END'),
|
||||||
|
('# P2S DNS RULES START', '# P2S DNS RULES END'),
|
||||||
|
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
|
||||||
|
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
|
||||||
|
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run(args, check=True):
|
||||||
|
result = subprocess.run(args, capture_output=True, text=True)
|
||||||
|
if check and result.returncode != 0:
|
||||||
|
fail(f'{" ".join(args)} failed: {result.stderr.strip()}')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def apply_routes(ctx):
|
||||||
|
"""Route the protected subnets via the configured internal interface.
|
||||||
|
|
||||||
|
validate() has already confirmed the interface exists.
|
||||||
|
"""
|
||||||
|
gateway = ctx['int_gateway_ip']
|
||||||
|
iface = ctx['int_iface']
|
||||||
|
for cidr in ctx['local_cidr_list']:
|
||||||
|
run(['ip', 'route', 'replace', cidr, 'via', gateway, 'dev', iface])
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_public_ip(fqdn):
|
||||||
|
try:
|
||||||
|
return socket.getaddrinfo(fqdn, None, socket.AF_INET)[0][4][0]
|
||||||
|
except socket.gaierror as exc:
|
||||||
|
fail(f'cannot resolve {fqdn} for local_id_mode=public_ip: {exc}')
|
||||||
|
|
||||||
|
|
||||||
|
def setup_wireguard(ctx):
|
||||||
|
"""Generate the WireGuard key pair when it does not exist."""
|
||||||
|
if not ctx['wg_enabled']:
|
||||||
|
return
|
||||||
|
WG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
key_file = WG_DIR / 'wg0.key'
|
||||||
|
pub_file = WG_DIR / 'wg0.pub'
|
||||||
|
|
||||||
|
if not key_file.exists() or key_file.stat().st_size == 0:
|
||||||
|
result = run(['wg', 'genkey'])
|
||||||
|
key_file.write_text(result.stdout)
|
||||||
|
os.chmod(key_file, 0o600)
|
||||||
|
|
||||||
|
result = subprocess.run(['wg', 'pubkey'], input=key_file.read_text(),
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
fail(f'wg pubkey failed: {result.stderr.strip()}')
|
||||||
|
pub_file.write_text(result.stdout)
|
||||||
|
os.chmod(pub_file, 0o644)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_blocks(text):
|
||||||
|
"""Remove every block this package owns, and the blank line before it."""
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
starts = {start for start, _ in BLOCKS}
|
||||||
|
ends = {end for _, end in BLOCKS}
|
||||||
|
|
||||||
|
out = []
|
||||||
|
skipping = False
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not skipping and stripped in starts:
|
||||||
|
while out and out[-1].strip() == '':
|
||||||
|
out.pop()
|
||||||
|
skipping = True
|
||||||
|
continue
|
||||||
|
if skipping:
|
||||||
|
if stripped in ends:
|
||||||
|
skipping = False
|
||||||
|
continue
|
||||||
|
out.append(line)
|
||||||
|
return ''.join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured(ctx):
|
||||||
|
"""True once any feature is actually configured.
|
||||||
|
|
||||||
|
While nothing is configured the package leaves the firewall alone: an
|
||||||
|
install that has not been filled in yet must not start blocking traffic on
|
||||||
|
a host that was working before.
|
||||||
|
"""
|
||||||
|
return any((vpnrouter.s2s_ready(ctx), vpnrouter.routing_ready(ctx),
|
||||||
|
vpnrouter.p2s_ready(ctx), vpnrouter.wg_ready(ctx)))
|
||||||
|
|
||||||
|
|
||||||
|
def filter_block(ctx):
|
||||||
|
lines = [
|
||||||
|
'',
|
||||||
|
'# IPSEC RULES START',
|
||||||
|
'-A ufw-before-input -p udp --dport 500 -j ACCEPT',
|
||||||
|
'-A ufw-before-input -p udp --dport 4500 -j ACCEPT',
|
||||||
|
'-A ufw-before-input -p esp -j ACCEPT',
|
||||||
|
'-A ufw-before-input -m policy --dir in --pol ipsec -j ACCEPT',
|
||||||
|
'-A ufw-before-output -m policy --dir out --pol ipsec -j ACCEPT',
|
||||||
|
'-A ufw-before-forward -m policy --dir in --pol ipsec -j ACCEPT',
|
||||||
|
'-A ufw-before-forward -m policy --dir out --pol ipsec -j ACCEPT',
|
||||||
|
'# IPSEC RULES END',
|
||||||
|
]
|
||||||
|
|
||||||
|
if vpnrouter.wg_ready(ctx):
|
||||||
|
lines += [
|
||||||
|
'',
|
||||||
|
'# WIREGUARD RULES START',
|
||||||
|
f'-A ufw-before-input -p udp --dport {ctx["wg_listen_port"]} -j ACCEPT',
|
||||||
|
'# WIREGUARD RULES END',
|
||||||
|
]
|
||||||
|
|
||||||
|
if vpnrouter.p2s_ready(ctx) and ctx['local_addrs']:
|
||||||
|
pool = ctx['p2s_address_pool']
|
||||||
|
lines += ['', '# P2S DNS RULES START']
|
||||||
|
for address in [a.strip() for a in ctx['local_addrs'].split(',') if a.strip()]:
|
||||||
|
lines += [
|
||||||
|
f'-A ufw-before-input -s {pool} -d {address} -p udp --dport 53 -j ACCEPT',
|
||||||
|
f'-A ufw-before-input -s {pool} -d {address} -p tcp --dport 53 -j ACCEPT',
|
||||||
|
]
|
||||||
|
lines += ['# P2S DNS RULES END']
|
||||||
|
|
||||||
|
if vpnrouter.routing_ready(ctx):
|
||||||
|
lines += ['', '# ROUTER FORWARD RULES START']
|
||||||
|
for subnet in ctx['local_cidr_list']:
|
||||||
|
lines += [
|
||||||
|
f'-A ufw-before-forward -s {subnet} -o {ctx["wan_iface"]} -j ACCEPT',
|
||||||
|
f'-A ufw-before-forward -d {subnet} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT',
|
||||||
|
]
|
||||||
|
lines += ['# ROUTER FORWARD RULES END']
|
||||||
|
|
||||||
|
return ''.join(line + '\n' for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def nat_and_mangle_block(ctx):
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
if vpnrouter.routing_ready(ctx):
|
||||||
|
subnets = ctx['local_cidr_list']
|
||||||
|
remote = [c.strip() for c in ctx['remote_cidrs'].split(',') if c.strip()]
|
||||||
|
lines += [
|
||||||
|
'',
|
||||||
|
'# ROUTER NAT RULES START',
|
||||||
|
'*nat',
|
||||||
|
':POSTROUTING ACCEPT [0:0]',
|
||||||
|
'-F POSTROUTING',
|
||||||
|
]
|
||||||
|
# Every RETURN precedes every MASQUERADE, so tunnel-bound traffic
|
||||||
|
# escapes NAT whichever local subnet it came from.
|
||||||
|
for subnet in subnets:
|
||||||
|
for cidr in remote:
|
||||||
|
lines.append(f'-A POSTROUTING -s {subnet} -d {cidr} -j RETURN')
|
||||||
|
for subnet in subnets:
|
||||||
|
lines.append(
|
||||||
|
f'-A POSTROUTING -s {subnet} -o {ctx["wan_iface"]} -j MASQUERADE')
|
||||||
|
lines += ['COMMIT', '# ROUTER NAT RULES END']
|
||||||
|
|
||||||
|
lines += [
|
||||||
|
'',
|
||||||
|
'# ROUTER MSS RULES START',
|
||||||
|
'*mangle',
|
||||||
|
':FORWARD ACCEPT [0:0]',
|
||||||
|
'-A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu',
|
||||||
|
'COMMIT',
|
||||||
|
'# ROUTER MSS RULES END',
|
||||||
|
]
|
||||||
|
|
||||||
|
return ''.join(line + '\n' for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_after_line(text, marker, block):
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
if line.strip() == marker:
|
||||||
|
lines.insert(index + 1, block)
|
||||||
|
return ''.join(lines)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def setup_ufw(ctx):
|
||||||
|
"""Rewrite the package's blocks in before.rules. Returns True on change."""
|
||||||
|
if not BEFORE_RULES.is_file():
|
||||||
|
fail(f'{BEFORE_RULES} does not exist')
|
||||||
|
|
||||||
|
# Nothing configured: remove anything left from an earlier configuration
|
||||||
|
# and touch nothing else.
|
||||||
|
if not is_configured(ctx):
|
||||||
|
original = BEFORE_RULES.read_text(encoding='utf-8')
|
||||||
|
text = strip_blocks(original)
|
||||||
|
if text == original:
|
||||||
|
return False
|
||||||
|
BEFORE_RULES.write_text(text, encoding='utf-8')
|
||||||
|
return True
|
||||||
|
|
||||||
|
if UFW_DEFAULTS.is_file():
|
||||||
|
text = UFW_DEFAULTS.read_text(encoding='utf-8')
|
||||||
|
updated = ''.join(
|
||||||
|
'DEFAULT_FORWARD_POLICY="ACCEPT"\n'
|
||||||
|
if line.startswith('DEFAULT_FORWARD_POLICY=') else line
|
||||||
|
for line in text.splitlines(keepends=True)
|
||||||
|
)
|
||||||
|
if updated != text:
|
||||||
|
UFW_DEFAULTS.write_text(updated, encoding='utf-8')
|
||||||
|
|
||||||
|
original = BEFORE_RULES.read_text(encoding='utf-8')
|
||||||
|
text = strip_blocks(original)
|
||||||
|
|
||||||
|
result = _insert_after_line(text, FILTER_ANCHOR, filter_block(ctx))
|
||||||
|
if result is None:
|
||||||
|
fail(f'{BEFORE_RULES}: anchor "{FILTER_ANCHOR}" not found')
|
||||||
|
text = result
|
||||||
|
|
||||||
|
result = _insert_after_line(text, 'COMMIT', nat_and_mangle_block(ctx))
|
||||||
|
if result is None:
|
||||||
|
fail(f'{BEFORE_RULES}: no COMMIT line found')
|
||||||
|
text = result
|
||||||
|
|
||||||
|
if text == original:
|
||||||
|
return False
|
||||||
|
BEFORE_RULES.write_text(text, encoding='utf-8')
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def unit_is_active(unit):
|
||||||
|
return run(['systemctl', 'is-active', '--quiet', unit],
|
||||||
|
check=False).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def manage_strongswan(ctx, changed):
|
||||||
|
"""Make sure the IPsec daemon is running and holding the current config.
|
||||||
|
|
||||||
|
strongswan.service loads swanctl configuration itself on start
|
||||||
|
(ExecStartPost) and re-reads it on reload, so starting it is enough on a
|
||||||
|
cold boot and a reload is the right response to a configuration change.
|
||||||
|
"""
|
||||||
|
wanted = vpnrouter.s2s_ready(ctx) or vpnrouter.p2s_ready(ctx)
|
||||||
|
if not wanted:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not unit_is_active('strongswan'):
|
||||||
|
run(['systemctl', 'enable', '--now', 'strongswan'], check=False)
|
||||||
|
elif 'swanctl' in changed:
|
||||||
|
run(['systemctl', 'reload', 'strongswan'], check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def manage_wireguard(ctx, changed):
|
||||||
|
"""Bring wg0 up when WireGuard is configured, and down when it is not."""
|
||||||
|
unit = 'wg-quick@wg0'
|
||||||
|
|
||||||
|
if not vpnrouter.wg_ready(ctx):
|
||||||
|
if unit_is_active(unit):
|
||||||
|
run(['systemctl', 'disable', '--now', unit], check=False)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not unit_is_active(unit):
|
||||||
|
run(['systemctl', 'enable', '--now', unit], check=False)
|
||||||
|
elif 'wireguard' in changed:
|
||||||
|
run(['systemctl', 'restart', unit], check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def run_platform_setup(cfg, ctx):
|
||||||
|
"""Let the platform module apply its own state, if it defines any."""
|
||||||
|
module = vpnrouter.platform_module(cfg)
|
||||||
|
apply = getattr(module, 'apply', None)
|
||||||
|
if apply is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
apply(ctx)
|
||||||
|
except Exception as exc:
|
||||||
|
fail(f'platform module {module.__name__} failed: {exc}')
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = vpnrouter.load_config()
|
||||||
|
ctx, changed = vpnrouter.configure(cfg)
|
||||||
|
|
||||||
|
if ctx is None:
|
||||||
|
print('vpn-router: platform is none, configuration deferred; '
|
||||||
|
'set general.platform in /etc/vpn-router/vpn-router.conf')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if vpnrouter.routing_ready(ctx):
|
||||||
|
apply_routes(ctx)
|
||||||
|
|
||||||
|
if ctx['local_id_mode'] == 'public_ip' and ctx['local_fqdn']:
|
||||||
|
address = resolve_public_ip(ctx['local_fqdn'])
|
||||||
|
ctx, more = vpnrouter.configure(cfg, local_id_override=address)
|
||||||
|
changed |= more
|
||||||
|
|
||||||
|
setup_wireguard(ctx)
|
||||||
|
|
||||||
|
rules_changed = setup_ufw(ctx)
|
||||||
|
if is_configured(ctx):
|
||||||
|
status = run(['ufw', 'status'], check=False)
|
||||||
|
if not status.stdout.startswith('Status: active'):
|
||||||
|
# Only ever enable the firewall after making sure SSH survives it.
|
||||||
|
run(['ufw', 'allow', '22/tcp'], check=False)
|
||||||
|
run(['ufw', '--force', 'enable'])
|
||||||
|
elif rules_changed:
|
||||||
|
run(['ufw', 'reload'])
|
||||||
|
elif rules_changed:
|
||||||
|
run(['ufw', 'reload'], check=False)
|
||||||
|
|
||||||
|
manage_strongswan(ctx, changed)
|
||||||
|
manage_wireguard(ctx, changed)
|
||||||
|
|
||||||
|
if 'resolved' in changed:
|
||||||
|
run(['systemctl', 'restart', 'systemd-resolved'], check=False)
|
||||||
|
|
||||||
|
run_platform_setup(cfg, ctx)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
+611
@@ -0,0 +1,611 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# MIT License
|
||||||
|
#
|
||||||
|
# Copyright (c) 2026 Sławomir Koszewski
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# This module requires Python 3.8+ and OpenSSL to be installed on the system.
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
OPENSSL = "openssl"
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
FILE = "simple-ca.json"
|
||||||
|
|
||||||
|
def __init__(self, ca_dir: str):
|
||||||
|
self._path = os.path.join(ca_dir, self.FILE)
|
||||||
|
self._data: dict = {}
|
||||||
|
if not os.path.isfile(self._path):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(self._path, "r") as f:
|
||||||
|
self._data = json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
print(f"WARNING: could not read {self._path}: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return self._data.get(key, default)
|
||||||
|
|
||||||
|
def update(self, patch: dict):
|
||||||
|
file_missing = not os.path.isfile(self._path)
|
||||||
|
changed = any(self._data.get(k) != v for k, v in patch.items())
|
||||||
|
if file_missing or changed:
|
||||||
|
self._data.update(patch)
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def append_history(self, ca_key: str, entry: dict):
|
||||||
|
history = self._data.get("history", {})
|
||||||
|
history.setdefault(ca_key, []).append(entry)
|
||||||
|
self._data["history"] = history
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def revoke_in_history(self, ca_key: str, serial: str, revoked_at: str):
|
||||||
|
"""Mark a certificate as revoked. Returns True if newly revoked,
|
||||||
|
False if already revoked, None if serial not found."""
|
||||||
|
history = self._data.get("history", {})
|
||||||
|
for entry in history.get(ca_key, []):
|
||||||
|
if entry.get("serial") == serial:
|
||||||
|
if "revoked" in entry:
|
||||||
|
return False
|
||||||
|
entry["revoked"] = revoked_at
|
||||||
|
self._data["history"] = history
|
||||||
|
self._save()
|
||||||
|
return True
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
with open(self._path, "w") as f:
|
||||||
|
json.dump(self._data, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
_config: Config
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def _err(msg):
|
||||||
|
print(f"ERROR: {msg}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_ca_bundle(ca_dir):
|
||||||
|
bundle_path = os.path.join(ca_dir, "ca_bundle.pem")
|
||||||
|
parts = []
|
||||||
|
root = os.path.join(ca_dir, "ca_cert.pem")
|
||||||
|
if os.path.isfile(root):
|
||||||
|
with open(root, "rb") as f:
|
||||||
|
parts.append(f.read())
|
||||||
|
for name in sorted(_config.get("subordinates", [])):
|
||||||
|
sub_cert = os.path.join(ca_dir, name, "ca_cert.pem")
|
||||||
|
if os.path.isfile(sub_cert):
|
||||||
|
with open(sub_cert, "rb") as f:
|
||||||
|
parts.append(f.read())
|
||||||
|
with open(bundle_path, "wb") as f:
|
||||||
|
f.write(b"".join(parts))
|
||||||
|
|
||||||
|
|
||||||
|
def _read_serial(cert_path) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
[OPENSSL, "x509", "-in", cert_path, "-noout", "-serial"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
return result.stdout.strip().split("=", 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _read_expiry(cert_path) -> str:
|
||||||
|
"""Return the certificate notAfter date as an ISO 8601 UTC string."""
|
||||||
|
result = subprocess.run(
|
||||||
|
[OPENSSL, "x509", "-in", cert_path, "-noout", "-enddate"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
raw = result.stdout.strip().split("=", 1)[-1]
|
||||||
|
raw = " ".join(raw.split()) # normalize whitespace ("May 4" -> "May 4")
|
||||||
|
dt = datetime.datetime.strptime(raw, "%b %d %H:%M:%S %Y GMT")
|
||||||
|
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_to_asn1(iso: str) -> str:
|
||||||
|
"""Convert "2026-05-24T14:28:10Z" -> "260524142810Z" for OpenSSL index.txt."""
|
||||||
|
dt = datetime.datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
return dt.strftime("%y%m%d%H%M%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
_IP_RE = re.compile(r"^[0-9]{1,3}(\.[0-9]{1,3}){3}$")
|
||||||
|
_DNS_RE = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)*$")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ip(value):
|
||||||
|
return bool(_IP_RE.match(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dns(value):
|
||||||
|
return bool(_DNS_RE.match(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _pipe(cmd1, cmd2):
|
||||||
|
p1 = subprocess.Popen(cmd1, stdout=subprocess.PIPE)
|
||||||
|
p2 = subprocess.Popen(cmd2, stdin=p1.stdout)
|
||||||
|
p1.stdout.close()
|
||||||
|
p2.communicate()
|
||||||
|
p1.wait()
|
||||||
|
return p1.returncode == 0 and p2.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, ca_publish_base_url=None):
|
||||||
|
if issuing_ca == "ca":
|
||||||
|
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not ca_dir or not os.path.isdir(ca_dir):
|
||||||
|
_err(f"Certificate directory {ca_dir} does not exist.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not ca_name:
|
||||||
|
_err("CA name is required.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
root_ca_cert_path = os.path.join(ca_dir, "ca_cert.pem")
|
||||||
|
root_ca_key_path = os.path.join(ca_dir, "ca_key.pem")
|
||||||
|
|
||||||
|
if not os.path.isfile(root_ca_cert_path) or not os.path.isfile(root_ca_key_path):
|
||||||
|
if issuing_ca:
|
||||||
|
_err(
|
||||||
|
f"Cannot create issuing CA '{ca_name}' without existing root CA "
|
||||||
|
"certificate and key. Please create the root CA first."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"Generating CA certificate '{ca_name}' and key...")
|
||||||
|
# Path length constraint of 1: allows one level of issuing CAs.
|
||||||
|
cmd = [
|
||||||
|
OPENSSL, "req",
|
||||||
|
"-x509",
|
||||||
|
"-newkey", "rsa:4096",
|
||||||
|
"-keyout", root_ca_key_path,
|
||||||
|
"-out", root_ca_cert_path,
|
||||||
|
"-days", str(days),
|
||||||
|
"-noenc",
|
||||||
|
"-subj", f"/CN={ca_name}",
|
||||||
|
"-text",
|
||||||
|
"-addext", "basicConstraints=critical,CA:TRUE,pathlen:1",
|
||||||
|
"-addext", "keyUsage=critical,keyCertSign,cRLSign",
|
||||||
|
]
|
||||||
|
if subprocess.run(cmd).returncode != 0:
|
||||||
|
_err("Failed to generate CA certificate and key.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
_rebuild_ca_bundle(ca_dir)
|
||||||
|
patch = {"name": ca_name, "created": _now()}
|
||||||
|
if ca_publish_base_url:
|
||||||
|
patch["ca_publish_base_url"] = ca_publish_base_url
|
||||||
|
_config.update(patch)
|
||||||
|
return True
|
||||||
|
|
||||||
|
issuing_ca_dir = os.path.join(ca_dir, issuing_ca)
|
||||||
|
issuing_ca_cert = os.path.join(issuing_ca_dir, "ca_cert.pem")
|
||||||
|
issuing_ca_key = os.path.join(issuing_ca_dir, "ca_key.pem")
|
||||||
|
|
||||||
|
if not os.path.isfile(issuing_ca_cert) or not os.path.isfile(issuing_ca_key):
|
||||||
|
print(f"Generating issuing CA certificate '{ca_name}' and key...")
|
||||||
|
os.makedirs(issuing_ca_dir, exist_ok=True)
|
||||||
|
req_cmd = [
|
||||||
|
OPENSSL, "req",
|
||||||
|
"-newkey", "rsa:4096",
|
||||||
|
"-keyout", issuing_ca_key,
|
||||||
|
"-noenc",
|
||||||
|
"-subj", f"/CN={ca_name}",
|
||||||
|
"-addext", "basicConstraints=critical,CA:TRUE,pathlen:0",
|
||||||
|
"-addext", "keyUsage=critical,keyCertSign,cRLSign",
|
||||||
|
]
|
||||||
|
if ca_publish_base_url:
|
||||||
|
req_cmd += [
|
||||||
|
"-addext", f"authorityInfoAccess=caIssuers;URI:{ca_publish_base_url}/ca_cert.crt",
|
||||||
|
"-addext", f"crlDistributionPoints=URI:{ca_publish_base_url}/crl.pem",
|
||||||
|
]
|
||||||
|
x509_cmd = [
|
||||||
|
OPENSSL, "x509",
|
||||||
|
"-req",
|
||||||
|
"-CA", root_ca_cert_path,
|
||||||
|
"-CAkey", root_ca_key_path,
|
||||||
|
"-copy_extensions", "copyall",
|
||||||
|
"-days", str(days),
|
||||||
|
"-text",
|
||||||
|
"-out", issuing_ca_cert,
|
||||||
|
]
|
||||||
|
if not _pipe(req_cmd, x509_cmd):
|
||||||
|
_err("Failed to generate issuing CA certificate and key.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
patch = {"ca_publish_base_url": ca_publish_base_url} if ca_publish_base_url else {}
|
||||||
|
subs = _config.get("subordinates", [])
|
||||||
|
if issuing_ca not in subs:
|
||||||
|
patch["subordinates"] = subs + [issuing_ca]
|
||||||
|
_config.update(patch)
|
||||||
|
_rebuild_ca_bundle(ca_dir)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def make_cert(cert_subject_name, sans=None, ca_dir=None, cert_dir=None,
|
||||||
|
issuing_ca=None, days=365, ca_publish_base_url=None):
|
||||||
|
if issuing_ca == "ca":
|
||||||
|
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not ca_dir or not os.path.isdir(ca_dir):
|
||||||
|
_err(f"CA directory {ca_dir} does not exist.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not cert_subject_name:
|
||||||
|
_err("Subject name is required.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not _is_dns(cert_subject_name):
|
||||||
|
_err(f"Invalid subject name '{cert_subject_name}'. Must be a valid DNS name.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir
|
||||||
|
cert_dir = cert_dir or signing_dir
|
||||||
|
|
||||||
|
if not os.path.isdir(cert_dir):
|
||||||
|
_err(f"Certificate directory {cert_dir} does not exist.")
|
||||||
|
return False
|
||||||
|
ca_cert_path = os.path.join(signing_dir, "ca_cert.pem")
|
||||||
|
ca_key_path = os.path.join(signing_dir, "ca_key.pem")
|
||||||
|
if not os.path.isfile(ca_cert_path) or not os.path.isfile(ca_key_path):
|
||||||
|
_err(
|
||||||
|
f"Signing CA certificate and key not found in {signing_dir}. "
|
||||||
|
"Please set up a signing CA first."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
aia_url = cdp_url = ""
|
||||||
|
if ca_publish_base_url:
|
||||||
|
if issuing_ca:
|
||||||
|
aia_url = f"{ca_publish_base_url}/{issuing_ca}/ca_cert.crt"
|
||||||
|
cdp_url = f"{ca_publish_base_url}/{issuing_ca}/crl.pem"
|
||||||
|
else:
|
||||||
|
aia_url = f"{ca_publish_base_url}/ca_cert.crt"
|
||||||
|
cdp_url = f"{ca_publish_base_url}/crl.pem"
|
||||||
|
|
||||||
|
cert_name = cert_subject_name.split(".", 1)[0]
|
||||||
|
|
||||||
|
san_entries = [f"DNS:{cert_subject_name}"]
|
||||||
|
for entry in sans or []:
|
||||||
|
if _is_ip(entry):
|
||||||
|
san_entries.append(f"IP:{entry}")
|
||||||
|
elif _is_dns(entry):
|
||||||
|
san_entries.append(f"DNS:{entry}")
|
||||||
|
else:
|
||||||
|
_err(f"Invalid SAN entry '{entry}'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
sans_ext = "subjectAltName=" + ",".join(san_entries)
|
||||||
|
|
||||||
|
print(f"Generating server certificate for '{cert_subject_name}' with SANs:")
|
||||||
|
for san in san_entries:
|
||||||
|
print(f" - {san}")
|
||||||
|
|
||||||
|
cert_out = os.path.join(cert_dir, f"{cert_name}_cert.pem")
|
||||||
|
key_out = os.path.join(cert_dir, f"{cert_name}_key.pem")
|
||||||
|
|
||||||
|
if not os.path.isfile(cert_out) or not os.path.isfile(key_out):
|
||||||
|
print("Generating server certificate and key...")
|
||||||
|
req_cmd = [
|
||||||
|
OPENSSL, "req",
|
||||||
|
"-newkey", "rsa:4096",
|
||||||
|
"-keyout", key_out,
|
||||||
|
"-noenc",
|
||||||
|
"-subj", f"/CN={cert_subject_name}",
|
||||||
|
"-addext", "basicConstraints=critical,CA:FALSE",
|
||||||
|
"-addext", "keyUsage=critical,digitalSignature,keyEncipherment",
|
||||||
|
"-addext", "extendedKeyUsage=serverAuth,clientAuth",
|
||||||
|
"-addext", sans_ext,
|
||||||
|
]
|
||||||
|
if aia_url:
|
||||||
|
req_cmd += ["-addext", f"authorityInfoAccess=caIssuers;URI:{aia_url}"]
|
||||||
|
if cdp_url:
|
||||||
|
req_cmd += ["-addext", f"crlDistributionPoints=URI:{cdp_url}"]
|
||||||
|
x509_cmd = [
|
||||||
|
OPENSSL, "x509",
|
||||||
|
"-req",
|
||||||
|
"-CA", ca_cert_path,
|
||||||
|
"-CAkey", ca_key_path,
|
||||||
|
"-copy_extensions", "copyall",
|
||||||
|
"-days", str(days),
|
||||||
|
"-text",
|
||||||
|
"-out", cert_out,
|
||||||
|
]
|
||||||
|
if not _pipe(req_cmd, x509_cmd):
|
||||||
|
_err("Failed to generate server certificate and key.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
_config.append_history(issuing_ca or "ca", {
|
||||||
|
"name": cert_subject_name,
|
||||||
|
"serial": _read_serial(cert_out),
|
||||||
|
"created": _now(),
|
||||||
|
"expires": _read_expiry(cert_out),
|
||||||
|
})
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def make_pfx(cert_path, ca_dir, issuing_ca=None, password=None, apple_openssl=False):
|
||||||
|
if issuing_ca == "ca":
|
||||||
|
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
cert_dir = os.path.dirname(cert_path)
|
||||||
|
cert_basename = os.path.basename(cert_path)
|
||||||
|
cert_name = cert_basename[:-len("_cert.pem")] if cert_basename.endswith("_cert.pem") else cert_basename
|
||||||
|
key_path = os.path.join(cert_dir, f"{cert_name}_key.pem")
|
||||||
|
|
||||||
|
if not cert_dir or not os.path.isdir(cert_dir):
|
||||||
|
_err(f"Certificate directory {cert_dir} does not exist.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not ca_dir or not os.path.isdir(ca_dir):
|
||||||
|
_err(f"CA directory {ca_dir} does not exist.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not os.path.isfile(cert_path) or not os.path.isfile(key_path):
|
||||||
|
_err("Server certificate or key not found.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
root_ca_cert_path = os.path.join(ca_dir, "ca_cert.pem")
|
||||||
|
root_ca_key_path = os.path.join(ca_dir, "ca_key.pem")
|
||||||
|
if not os.path.isfile(root_ca_cert_path) or not os.path.isfile(root_ca_key_path):
|
||||||
|
_err(f"CA certificate or key not found in {ca_dir}.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if issuing_ca:
|
||||||
|
issuing_ca_cert_path = os.path.join(ca_dir, issuing_ca, "ca_cert.pem")
|
||||||
|
if not os.path.isfile(issuing_ca_cert_path):
|
||||||
|
_err(f"Issuing CA certificate not found: {issuing_ca_cert_path}.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not password:
|
||||||
|
password = "changeit"
|
||||||
|
|
||||||
|
pfx_path = os.path.join(cert_dir, f"{cert_name}.pfx")
|
||||||
|
if os.path.isfile(pfx_path):
|
||||||
|
print("PKCS#12 (PFX) file already exists, aborting generation.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("Generating PKCS#12 (PFX) file...", end="")
|
||||||
|
|
||||||
|
chain_bytes = b""
|
||||||
|
with open(root_ca_cert_path, "rb") as f:
|
||||||
|
chain_bytes += f.read()
|
||||||
|
if issuing_ca:
|
||||||
|
with open(issuing_ca_cert_path, "rb") as f:
|
||||||
|
chain_bytes += f.read()
|
||||||
|
|
||||||
|
chain_fd, chain_file = tempfile.mkstemp()
|
||||||
|
try:
|
||||||
|
with os.fdopen(chain_fd, "wb") as f:
|
||||||
|
f.write(chain_bytes)
|
||||||
|
cmd = [
|
||||||
|
"/usr/bin/openssl" if apple_openssl else OPENSSL, "pkcs12",
|
||||||
|
"-export", "-out", pfx_path,
|
||||||
|
"-inkey", key_path,
|
||||||
|
"-in", cert_path,
|
||||||
|
"-certfile", chain_file,
|
||||||
|
"-password", f"pass:{password}",
|
||||||
|
]
|
||||||
|
if subprocess.run(cmd).returncode != 0:
|
||||||
|
_err("Failed to generate PKCS#12 (PFX) file.")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
if os.path.exists(chain_file):
|
||||||
|
os.remove(chain_file)
|
||||||
|
|
||||||
|
print("done.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def make_crl(ca_dir, issuing_ca=None, days=30):
|
||||||
|
signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir
|
||||||
|
ca_cert = os.path.join(signing_dir, "ca_cert.pem")
|
||||||
|
ca_key = os.path.join(signing_dir, "ca_key.pem")
|
||||||
|
|
||||||
|
if not os.path.isfile(ca_cert) or not os.path.isfile(ca_key):
|
||||||
|
_err(f"CA certificate or key not found in {signing_dir}.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
crl_path = os.path.join(signing_dir, "crl.pem")
|
||||||
|
|
||||||
|
history_key = issuing_ca or "ca"
|
||||||
|
revoked_entries = [
|
||||||
|
e for e in _config.get("history", {}).get(history_key, [])
|
||||||
|
if "revoked" in e and "expires" in e
|
||||||
|
]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
index_txt = os.path.join(tmp, "index.txt")
|
||||||
|
crlnumber = os.path.join(tmp, "crlnumber")
|
||||||
|
cnf_path = os.path.join(tmp, "openssl.cnf")
|
||||||
|
|
||||||
|
with open(index_txt, "w") as f:
|
||||||
|
for e in revoked_entries:
|
||||||
|
expires_asn1 = _iso_to_asn1(e["expires"])
|
||||||
|
revoked_asn1 = _iso_to_asn1(e["revoked"])
|
||||||
|
f.write(f"R\t{expires_asn1}\t{revoked_asn1}\t{e['serial']}\tunknown\t/CN={e['name']}\n")
|
||||||
|
with open(crlnumber, "w") as f:
|
||||||
|
f.write("01\n")
|
||||||
|
with open(cnf_path, "w") as f:
|
||||||
|
f.write(
|
||||||
|
"[ ca ]\ndefault_ca = CA_default\n\n"
|
||||||
|
"[ CA_default ]\n"
|
||||||
|
f"database = {index_txt}\n"
|
||||||
|
f"crlnumber = {crlnumber}\n"
|
||||||
|
f"certificate = {ca_cert}\n"
|
||||||
|
f"private_key = {ca_key}\n"
|
||||||
|
f"default_crl_days = {days}\n"
|
||||||
|
"default_md = sha256\n\n"
|
||||||
|
"[ crl_ext ]\n"
|
||||||
|
"authorityKeyIdentifier = keyid:always\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if subprocess.run([OPENSSL, "ca", "-gencrl", "-config", cnf_path, "-out", crl_path]).returncode != 0:
|
||||||
|
_err("Failed to generate CRL.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"CRL written to {crl_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_cert(cert_path, ca_dir, issuing_ca=None):
|
||||||
|
if not os.path.isfile(cert_path):
|
||||||
|
_err(f"Certificate not found: {cert_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir
|
||||||
|
if not os.path.isdir(signing_dir):
|
||||||
|
_err(f"CA directory not found: {signing_dir}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
ca_key = issuing_ca or "ca"
|
||||||
|
serial = _read_serial(cert_path)
|
||||||
|
result = _config.revoke_in_history(ca_key, serial, _now())
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
_err(f"Certificate with serial {serial} not found in history for CA '{ca_key}'.")
|
||||||
|
return False
|
||||||
|
if result is False:
|
||||||
|
print(f"Certificate {cert_path} (serial {serial}) is already revoked.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
print(f"Certificate {cert_path} (serial {serial}) marked as revoked.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _build_parser():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Simple CA for creating and managing test certificates."
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
p_ca = sub.add_parser("make-ca", help="Create a root or issuing CA.")
|
||||||
|
p_ca.add_argument("--days", type=int, default=None, help="Validity period in days (default: 3650)")
|
||||||
|
p_ca.add_argument("--issuing-ca", default=None, help="Specify the issuing CA")
|
||||||
|
p_ca.add_argument("--ca-publish-base-url", default=None,
|
||||||
|
help="Base URL for AIA and CRL distribution point extensions")
|
||||||
|
p_ca.add_argument("--ca-dir", help="Directory to store the CA files")
|
||||||
|
p_ca.add_argument("--openssl", default=None, metavar="PATH",
|
||||||
|
help=f"Path to the openssl binary (default: {OPENSSL})")
|
||||||
|
p_ca.add_argument("ca_name", help="Name of the CA")
|
||||||
|
|
||||||
|
p_cert = sub.add_parser("make-cert", help="Create a server/client certificate.")
|
||||||
|
p_cert.add_argument("--ca-dir", help="Directory of the CA")
|
||||||
|
p_cert.add_argument("--issuing-ca", default=None, help="Specify the issuing CA")
|
||||||
|
p_cert.add_argument("--days", type=int, default=None, help="Validity period in days (default: 365)")
|
||||||
|
p_cert.add_argument("--cert-dir", help="Directory to store the certificate files")
|
||||||
|
p_cert.add_argument("--openssl", default=None, metavar="PATH",
|
||||||
|
help=f"Path to the openssl binary (default: {OPENSSL})")
|
||||||
|
p_cert.add_argument("subject_name", help="Subject name for the certificate")
|
||||||
|
p_cert.add_argument("sans", nargs="*", help="Subject Alternative Names (SANs) for the certificate")
|
||||||
|
|
||||||
|
p_pfx = sub.add_parser("make-pfx", help="Create a PKCS#12 (PFX) bundle.")
|
||||||
|
p_pfx.add_argument("--issuing-ca", default=None, help="Specify the issuing CA")
|
||||||
|
p_pfx.add_argument("--ca-dir", help="Directory of the CA")
|
||||||
|
p_pfx.add_argument("--password", help="Password for the PFX file")
|
||||||
|
p_pfx.add_argument("--apple-openssl", action="store_true", default=False,
|
||||||
|
help="Use Apple's bundled /usr/bin/openssl for PKCS12 generation")
|
||||||
|
p_pfx.add_argument("path", help="Path to the certificate file")
|
||||||
|
|
||||||
|
p_crl = sub.add_parser("make-crl", help="Generate a CRL for a CA.")
|
||||||
|
p_crl.add_argument("--ca-dir", help="Directory of the CA")
|
||||||
|
p_crl.add_argument("--issuing-ca", default=None, help="Generate CRL for this issuing CA")
|
||||||
|
p_crl.add_argument("--days", type=int, default=None, help="CRL validity in days (default: 30)")
|
||||||
|
|
||||||
|
p_rev = sub.add_parser("revoke-cert", help="Revoke a certificate.")
|
||||||
|
p_rev.add_argument("--ca-dir", help="Directory of the CA")
|
||||||
|
p_rev.add_argument("--issuing-ca", default=None, help="Issuing CA that signed the certificate")
|
||||||
|
p_rev.add_argument("cert_path", help="Path to the certificate file to revoke")
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
global OPENSSL, _config
|
||||||
|
|
||||||
|
parser = _build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
ca_dir = args.ca_dir or os.environ.get("SIMPLE_CA_DIR") or os.getcwd()
|
||||||
|
|
||||||
|
_config = Config(ca_dir)
|
||||||
|
|
||||||
|
OPENSSL = getattr(args, "openssl", None) or _config.get("openssl", OPENSSL)
|
||||||
|
issuing_ca = args.issuing_ca or _config.get("issuing_ca")
|
||||||
|
ca_publish_base_url = getattr(args, "ca_publish_base_url", None) or _config.get("ca_publish_base_url")
|
||||||
|
|
||||||
|
days_cfg = _config.get("days", {})
|
||||||
|
|
||||||
|
if args.command == "make-ca":
|
||||||
|
days = args.days or days_cfg.get("ca", 3650)
|
||||||
|
ok = make_ca(
|
||||||
|
ca_dir, args.ca_name,
|
||||||
|
days=days,
|
||||||
|
issuing_ca=issuing_ca,
|
||||||
|
ca_publish_base_url=ca_publish_base_url,
|
||||||
|
)
|
||||||
|
elif args.command == "make-cert":
|
||||||
|
days = args.days or days_cfg.get("cert", 365)
|
||||||
|
ok = make_cert(
|
||||||
|
args.subject_name,
|
||||||
|
sans=args.sans,
|
||||||
|
ca_dir=ca_dir,
|
||||||
|
cert_dir=getattr(args, "cert_dir", None),
|
||||||
|
issuing_ca=issuing_ca,
|
||||||
|
days=days,
|
||||||
|
ca_publish_base_url=ca_publish_base_url,
|
||||||
|
)
|
||||||
|
elif args.command == "make-pfx":
|
||||||
|
ok = make_pfx(
|
||||||
|
args.path, ca_dir,
|
||||||
|
issuing_ca=issuing_ca,
|
||||||
|
password=args.password,
|
||||||
|
apple_openssl=args.apple_openssl,
|
||||||
|
)
|
||||||
|
elif args.command == "make-crl":
|
||||||
|
days = args.days or days_cfg.get("crl", 30)
|
||||||
|
ok = make_crl(ca_dir, issuing_ca=issuing_ca, days=days)
|
||||||
|
elif args.command == "revoke-cert":
|
||||||
|
ok = revoke_cert(args.cert_path, ca_dir, issuing_ca=issuing_ca)
|
||||||
|
else:
|
||||||
|
parser.error(f"Unknown command: {args.command}")
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
"""Shared library for the vpn-router package.
|
||||||
|
|
||||||
|
Everything derivable from the configuration file alone, with no running
|
||||||
|
network required. Used by both 'configure' and 'setup'.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import configparser
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
|
||||||
|
LIB_DIR = pathlib.Path(__file__).resolve().parent
|
||||||
|
if str(LIB_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(LIB_DIR))
|
||||||
|
|
||||||
|
import vpnrouter_platforms
|
||||||
|
|
||||||
|
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.conf')
|
||||||
|
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
|
||||||
|
PKI_DIR = pathlib.Path('/etc/vpn-router/pki')
|
||||||
|
SIMPLE_CA = LIB_DIR / 'simple-ca'
|
||||||
|
|
||||||
|
SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d')
|
||||||
|
REMOTE_SITE = SWANCTL_CONF_D / 'remote-site.conf'
|
||||||
|
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf'
|
||||||
|
P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
|
||||||
|
WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf')
|
||||||
|
|
||||||
|
CA_CERT_DEST = pathlib.Path('/etc/swanctl/x509ca/ca.pem')
|
||||||
|
SERVER_CERT = pathlib.Path('/etc/swanctl/x509/server.pem')
|
||||||
|
SERVER_KEY = pathlib.Path('/etc/swanctl/private/server.key')
|
||||||
|
CRL_DEST = pathlib.Path('/etc/swanctl/x509crl/crl.pem')
|
||||||
|
TRUST_ANCHOR = pathlib.Path('/usr/local/share/ca-certificates/vpn-router-ca.crt')
|
||||||
|
|
||||||
|
# section -> option -> default
|
||||||
|
SCHEMA = {
|
||||||
|
'general': {'platform': 'none', 'mode': 'manual'},
|
||||||
|
'interfaces': {'external': '', 'internal': ''},
|
||||||
|
'wan': {'local_fqdn': '', 'local_id_mode': 'fqdn'},
|
||||||
|
'local': {'cidrs': '', 'int_addr': '', 'int_gateway_ip': ''},
|
||||||
|
'remote': {'addrs': '', 'id': '', 'cidrs': '', 'psk_b64': '', 'psk_file': ''},
|
||||||
|
'p2s': {'enabled': 'false', 'address_pool': '', 'ca_name': 'VPN Router CA'},
|
||||||
|
'wireguard': {'enabled': 'false', 'address': '', 'listen_port': '51820'},
|
||||||
|
}
|
||||||
|
|
||||||
|
ID_MODES = ('fqdn', 'public_ip', 'internal_ip')
|
||||||
|
|
||||||
|
#: How much the operator supplies, and therefore how much may be detected.
|
||||||
|
#: manual - names and addresses given; nothing is detected
|
||||||
|
#: interfaces - names given; addresses and gateway derived from them
|
||||||
|
#: auto - nothing given; the interfaces are worked out too, accepting
|
||||||
|
#: that the result may be wrong
|
||||||
|
MODES = ('manual', 'interfaces', 'auto')
|
||||||
|
|
||||||
|
#: platform = none means configuration is deferred: files are installed and
|
||||||
|
#: nothing else happens until the operator says otherwise.
|
||||||
|
PLATFORM_NONE = 'none'
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
"""A setting is present but malformed."""
|
||||||
|
|
||||||
|
|
||||||
|
def warn(message):
|
||||||
|
print(f'vpn-router: warning: {message}', file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message):
|
||||||
|
print(f'vpn-router: error: {message}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def new_parser():
|
||||||
|
"""A parser that tolerates operator comments and never interpolates.
|
||||||
|
|
||||||
|
interpolation=None matters: the default BasicInterpolation treats '%' as
|
||||||
|
syntax, which would break any value containing it.
|
||||||
|
|
||||||
|
Inline comments are enabled, which configparser leaves off by default
|
||||||
|
because it makes '#' and ';' unusable inside values. That is an acceptable
|
||||||
|
trade here precisely because the one setting that can hold arbitrary
|
||||||
|
characters, the pre-shared key, is base64-encoded, and base64 uses none of
|
||||||
|
those characters.
|
||||||
|
"""
|
||||||
|
return configparser.ConfigParser(
|
||||||
|
interpolation=None,
|
||||||
|
comment_prefixes=('#', ';'),
|
||||||
|
inline_comment_prefixes=('#', ';'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_parser():
|
||||||
|
parser = new_parser()
|
||||||
|
for section, options in SCHEMA.items():
|
||||||
|
parser[section] = dict(options)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path=None):
|
||||||
|
"""Read the configuration, filling in defaults for anything absent.
|
||||||
|
|
||||||
|
The path is resolved when called, not when this function is defined, so
|
||||||
|
CONFIG_FILE stays overridable.
|
||||||
|
"""
|
||||||
|
path = pathlib.Path(path) if path else CONFIG_FILE
|
||||||
|
parser = default_parser()
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
parser.read(path, encoding='utf-8')
|
||||||
|
except configparser.Error as exc:
|
||||||
|
fail(f'cannot parse {path}: {exc}')
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def _split_list(value):
|
||||||
|
return [item.strip() for item in value.split(',') if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _get(cfg, section, option):
|
||||||
|
return cfg.get(section, option, fallback=SCHEMA[section][option]).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bool(cfg, section, option):
|
||||||
|
try:
|
||||||
|
return cfg.getboolean(section, option,
|
||||||
|
fallback=SCHEMA[section][option] == 'true')
|
||||||
|
except ValueError:
|
||||||
|
raise ConfigError(f'{section}.{option} is not a boolean')
|
||||||
|
|
||||||
|
|
||||||
|
def _check_networks(value, name):
|
||||||
|
for item in _split_list(value):
|
||||||
|
try:
|
||||||
|
ipaddress.ip_network(item, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
raise ConfigError(f'{name}: {item!r} is not a valid CIDR')
|
||||||
|
|
||||||
|
|
||||||
|
def _check_address(value, name):
|
||||||
|
if not value:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(value)
|
||||||
|
except ValueError:
|
||||||
|
raise ConfigError(f'{name}: {value!r} is not a valid IP address')
|
||||||
|
|
||||||
|
|
||||||
|
def validate(cfg):
|
||||||
|
"""Reject values that are present but malformed. Absent is not an error.
|
||||||
|
|
||||||
|
The exception is general.mode, which states what the operator promised to
|
||||||
|
supply, so the values that mode requires are checked for presence too.
|
||||||
|
"""
|
||||||
|
platform = _get(cfg, 'general', 'platform')
|
||||||
|
installed = [PLATFORM_NONE] + vpnrouter_platforms.available()
|
||||||
|
if platform and platform not in installed:
|
||||||
|
raise ConfigError(
|
||||||
|
f'general.platform: {platform!r} is not installed, expected one of '
|
||||||
|
f'{", ".join(installed)}')
|
||||||
|
|
||||||
|
mode = _get(cfg, 'general', 'mode')
|
||||||
|
if mode and mode not in MODES:
|
||||||
|
raise ConfigError(
|
||||||
|
f'general.mode: {mode!r} is not one of {", ".join(MODES)}')
|
||||||
|
|
||||||
|
if not deferred(cfg):
|
||||||
|
required = {
|
||||||
|
'manual': (('interfaces', 'external'), ('interfaces', 'internal'),
|
||||||
|
('local', 'int_addr'), ('local', 'int_gateway_ip')),
|
||||||
|
'interfaces': (('interfaces', 'external'), ('interfaces', 'internal')),
|
||||||
|
'auto': (),
|
||||||
|
}[mode or 'manual']
|
||||||
|
for section, option in required:
|
||||||
|
if not _get(cfg, section, option):
|
||||||
|
raise ConfigError(
|
||||||
|
f'{section}.{option} is required in mode {mode or "manual"}')
|
||||||
|
|
||||||
|
id_mode = _get(cfg, 'wan', 'local_id_mode')
|
||||||
|
if id_mode and id_mode not in ID_MODES:
|
||||||
|
raise ConfigError(
|
||||||
|
f'wan.local_id_mode: {id_mode!r} is not one of {", ".join(ID_MODES)}')
|
||||||
|
|
||||||
|
_check_networks(_get(cfg, 'local', 'cidrs'), 'local.cidrs')
|
||||||
|
_check_networks(_get(cfg, 'remote', 'cidrs'), 'remote.cidrs')
|
||||||
|
_check_networks(_get(cfg, 'p2s', 'address_pool'), 'p2s.address_pool')
|
||||||
|
_check_address(_get(cfg, 'local', 'int_addr'), 'local.int_addr')
|
||||||
|
_check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip')
|
||||||
|
|
||||||
|
# In auto mode the names are worked out later, so there is nothing to check.
|
||||||
|
if mode != 'auto':
|
||||||
|
for option in ('external', 'internal'):
|
||||||
|
name = _get(cfg, 'interfaces', option)
|
||||||
|
if name and not (pathlib.Path('/sys/class/net') / name).exists():
|
||||||
|
raise ConfigError(
|
||||||
|
f'interfaces.{option}: interface {name!r} does not exist')
|
||||||
|
|
||||||
|
wg_address = _get(cfg, 'wireguard', 'address')
|
||||||
|
if wg_address:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_interface(wg_address)
|
||||||
|
except ValueError:
|
||||||
|
raise ConfigError(
|
||||||
|
f'wireguard.address: {wg_address!r} is not an address with prefix')
|
||||||
|
|
||||||
|
port = _get(cfg, 'wireguard', 'listen_port')
|
||||||
|
if port:
|
||||||
|
if not port.isdigit() or not 1 <= int(port) <= 65535:
|
||||||
|
raise ConfigError(f'wireguard.listen_port: {port!r} is not a valid port')
|
||||||
|
|
||||||
|
for section in ('p2s', 'wireguard'):
|
||||||
|
_get_bool(cfg, section, 'enabled')
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_psk(cfg):
|
||||||
|
"""Return the pre-shared key, or '' when none is configured."""
|
||||||
|
psk_file = _get(cfg, 'remote', 'psk_file')
|
||||||
|
if psk_file:
|
||||||
|
path = pathlib.Path(psk_file)
|
||||||
|
if not path.is_file():
|
||||||
|
raise ConfigError(f'remote.psk_file: {psk_file} does not exist')
|
||||||
|
return path.read_text(encoding='utf-8').strip()
|
||||||
|
|
||||||
|
psk_b64 = _get(cfg, 'remote', 'psk_b64')
|
||||||
|
if not psk_b64:
|
||||||
|
return ''
|
||||||
|
try:
|
||||||
|
return base64.b64decode(psk_b64, validate=True).decode('utf-8')
|
||||||
|
except (binascii.Error, UnicodeDecodeError) as exc:
|
||||||
|
raise ConfigError(f'remote.psk_b64 is not valid base64 text: {exc}')
|
||||||
|
|
||||||
|
|
||||||
|
def platform_module(cfg):
|
||||||
|
"""Import the configured platform module, falling back to the default."""
|
||||||
|
name = _get(cfg, 'general', 'platform') or vpnrouter_platforms.DEFAULT
|
||||||
|
if name == PLATFORM_NONE:
|
||||||
|
# Not normally reached: configure() returns before this when deferred.
|
||||||
|
name = vpnrouter_platforms.DEFAULT
|
||||||
|
try:
|
||||||
|
return vpnrouter_platforms.load(name)
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
warn(f'platform {name!r} is not installed, using '
|
||||||
|
f'{vpnrouter_platforms.DEFAULT}')
|
||||||
|
return vpnrouter_platforms.load(vpnrouter_platforms.DEFAULT)
|
||||||
|
|
||||||
|
|
||||||
|
def _join(*groups):
|
||||||
|
items = []
|
||||||
|
for group in groups:
|
||||||
|
for item in _split_list(group):
|
||||||
|
if item not in items:
|
||||||
|
items.append(item)
|
||||||
|
return ', '.join(items)
|
||||||
|
|
||||||
|
|
||||||
|
def deferred(cfg):
|
||||||
|
"""True when platform is none, meaning nothing is to be configured yet."""
|
||||||
|
return (_get(cfg, 'general', 'platform') or PLATFORM_NONE) == PLATFORM_NONE
|
||||||
|
|
||||||
|
|
||||||
|
def _ip_route(*args):
|
||||||
|
result = subprocess.run(['ip', *args], capture_output=True, text=True)
|
||||||
|
return result.stdout if result.returncode == 0 else ''
|
||||||
|
|
||||||
|
|
||||||
|
def detect_interfaces():
|
||||||
|
"""Work out the external and internal interface names.
|
||||||
|
|
||||||
|
Only reached in auto mode, where the operator has accepted that a wrong
|
||||||
|
guess is possible. The external interface is whichever one carries the
|
||||||
|
default route; the internal one is the only other addressed interface.
|
||||||
|
"""
|
||||||
|
tokens = _ip_route('route', 'get', '1.1.1.1').split()
|
||||||
|
external = ''
|
||||||
|
for index, token in enumerate(tokens):
|
||||||
|
if token == 'dev' and index + 1 < len(tokens):
|
||||||
|
external = tokens[index + 1]
|
||||||
|
break
|
||||||
|
if not external:
|
||||||
|
raise ConfigError('auto mode: cannot determine the external interface')
|
||||||
|
|
||||||
|
candidates = [name for name in _addressed_interfaces() if name != external]
|
||||||
|
if len(candidates) != 1:
|
||||||
|
raise ConfigError(
|
||||||
|
f'auto mode: expected one internal interface, found {len(candidates)}; '
|
||||||
|
'use mode = interfaces and name them')
|
||||||
|
return external, candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _addressed_interfaces():
|
||||||
|
"""Interface names holding at least one IPv4 address, excluding loopback."""
|
||||||
|
names = []
|
||||||
|
for line in _ip_route('-o', '-4', 'addr', 'show').splitlines():
|
||||||
|
fields = line.split()
|
||||||
|
if len(fields) >= 4 and fields[1] != 'lo' and fields[1] not in names:
|
||||||
|
names.append(fields[1])
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def detect_gateway(iface, address):
|
||||||
|
"""Derive the next hop on the internal side.
|
||||||
|
|
||||||
|
The routing table is the honest source. When it has nothing to say, fall
|
||||||
|
back to the first host address of the connected subnet, which is a
|
||||||
|
convention rather than a fact - so say so.
|
||||||
|
"""
|
||||||
|
for line in _ip_route('-o', 'route', 'show', 'dev', iface).splitlines():
|
||||||
|
fields = line.split()
|
||||||
|
if 'via' in fields:
|
||||||
|
return fields[fields.index('via') + 1]
|
||||||
|
|
||||||
|
for line in _ip_route('-o', '-4', 'addr', 'show', 'dev', iface).splitlines():
|
||||||
|
fields = line.split()
|
||||||
|
if len(fields) >= 4 and fields[3].split('/')[0] == address:
|
||||||
|
network = ipaddress.ip_interface(fields[3]).network
|
||||||
|
gateway = str(next(network.hosts()))
|
||||||
|
warn(f'{iface}: no gateway in the routing table, assuming {gateway} '
|
||||||
|
f'as the first host of {network}')
|
||||||
|
return gateway
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def interface_addresses(name, setting):
|
||||||
|
"""IPv4 addresses configured on an interface.
|
||||||
|
|
||||||
|
The interface name is configuration; its addresses are read from the
|
||||||
|
system. Looking up the addresses of a named interface is deterministic -
|
||||||
|
unlike working out which interface to use, which is a guess the package
|
||||||
|
deliberately does not make.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return []
|
||||||
|
if not (pathlib.Path('/sys/class/net') / name).exists():
|
||||||
|
raise ConfigError(f'{setting}: interface {name!r} does not exist')
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
['ip', '-o', '-4', 'addr', 'show', 'dev', name],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise ConfigError(f'{setting}: cannot read addresses of {name!r}')
|
||||||
|
|
||||||
|
addresses = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
fields = line.split()
|
||||||
|
if len(fields) >= 4:
|
||||||
|
addresses.append(fields[3].split('/')[0])
|
||||||
|
return addresses
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_interfaces(cfg):
|
||||||
|
"""Return (ext_iface, int_iface, local_addrs, int_addr, int_gateway_ip).
|
||||||
|
|
||||||
|
What is read from the system and what is taken verbatim depends entirely on
|
||||||
|
general.mode. In manual mode nothing below touches the system at all.
|
||||||
|
"""
|
||||||
|
mode = _get(cfg, 'general', 'mode') or 'manual'
|
||||||
|
ext_iface = _get(cfg, 'interfaces', 'external')
|
||||||
|
int_iface = _get(cfg, 'interfaces', 'internal')
|
||||||
|
int_addr = _get(cfg, 'local', 'int_addr')
|
||||||
|
gateway = _get(cfg, 'local', 'int_gateway_ip')
|
||||||
|
|
||||||
|
if mode == 'auto':
|
||||||
|
ext_iface, int_iface = detect_interfaces()
|
||||||
|
|
||||||
|
ext_addrs = interface_addresses(ext_iface, 'interfaces.external')
|
||||||
|
local_addrs = ', '.join(ext_addrs)
|
||||||
|
if ext_iface and not ext_addrs:
|
||||||
|
warn(f'{ext_iface} has no IPv4 address yet')
|
||||||
|
|
||||||
|
if mode == 'manual':
|
||||||
|
return ext_iface, int_iface, local_addrs, int_addr, gateway
|
||||||
|
|
||||||
|
int_addrs = interface_addresses(int_iface, 'interfaces.internal')
|
||||||
|
int_addr = int_addrs[0] if int_addrs else ''
|
||||||
|
if int_iface and not int_addr:
|
||||||
|
warn(f'{int_iface} has no IPv4 address yet')
|
||||||
|
gateway = detect_gateway(int_iface, int_addr) if int_addr else ''
|
||||||
|
return ext_iface, int_iface, local_addrs, int_addr, gateway
|
||||||
|
|
||||||
|
|
||||||
|
def build_context(cfg, local_id_override=None):
|
||||||
|
"""Assemble the template context from the configuration."""
|
||||||
|
extras = vpnrouter_platforms.context(platform_module(cfg))
|
||||||
|
|
||||||
|
ext_iface, int_iface, local_addrs, int_addr, gateway = resolve_interfaces(cfg)
|
||||||
|
|
||||||
|
local_fqdn = _get(cfg, 'wan', 'local_fqdn')
|
||||||
|
id_mode = _get(cfg, 'wan', 'local_id_mode') or 'fqdn'
|
||||||
|
local_cidrs = _get(cfg, 'local', 'cidrs')
|
||||||
|
|
||||||
|
if local_id_override:
|
||||||
|
local_id = local_id_override
|
||||||
|
elif id_mode == 'fqdn':
|
||||||
|
local_id = f'@{local_fqdn}' if local_fqdn else ''
|
||||||
|
elif id_mode == 'internal_ip':
|
||||||
|
local_id = int_addr
|
||||||
|
else:
|
||||||
|
# public_ip is resolved once the network is up, and the caller then
|
||||||
|
# re-renders with local_id_override set.
|
||||||
|
local_id = ''
|
||||||
|
|
||||||
|
cidr_list = _split_list(local_cidrs)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'platform': _get(cfg, 'general', 'platform') or PLATFORM_NONE,
|
||||||
|
'mode': _get(cfg, 'general', 'mode') or 'manual',
|
||||||
|
'wan_iface': ext_iface,
|
||||||
|
'int_iface': int_iface,
|
||||||
|
'local_addrs': local_addrs,
|
||||||
|
'local_addr_list': _split_list(local_addrs),
|
||||||
|
'local_fqdn': local_fqdn,
|
||||||
|
'local_id_mode': id_mode,
|
||||||
|
'local_id': local_id,
|
||||||
|
'local_cidrs': local_cidrs,
|
||||||
|
'local_cidr_list': cidr_list,
|
||||||
|
'local_ts': _join(local_cidrs, extras['EXTRA_LOCAL_TS']),
|
||||||
|
'int_addr': int_addr,
|
||||||
|
'int_gateway_ip': gateway,
|
||||||
|
'remote_addrs': _get(cfg, 'remote', 'addrs'),
|
||||||
|
'remote_id': _get(cfg, 'remote', 'id'),
|
||||||
|
'remote_cidrs': _get(cfg, 'remote', 'cidrs'),
|
||||||
|
'psk': resolve_psk(cfg),
|
||||||
|
'p2s_enabled': _get_bool(cfg, 'p2s', 'enabled'),
|
||||||
|
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
|
||||||
|
'p2s_ca_name': _get(cfg, 'p2s', 'ca_name'),
|
||||||
|
'p2s_dns': _join(local_addrs, extras['EXTRA_P2S_DNS']),
|
||||||
|
'wg_enabled': _get_bool(cfg, 'wireguard', 'enabled'),
|
||||||
|
'wg_address': _get(cfg, 'wireguard', 'address'),
|
||||||
|
'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def s2s_ready(ctx):
|
||||||
|
return all([
|
||||||
|
ctx['local_addrs'], ctx['local_fqdn'], ctx['local_cidrs'],
|
||||||
|
ctx['remote_addrs'], ctx['remote_id'], ctx['remote_cidrs'],
|
||||||
|
ctx['psk'], ctx['local_id'],
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def routing_ready(ctx):
|
||||||
|
"""Routing and NAT need both interfaces named, plus the subnets involved."""
|
||||||
|
return all([ctx['local_cidrs'], ctx['int_gateway_ip'],
|
||||||
|
ctx['int_iface'], ctx['wan_iface']])
|
||||||
|
|
||||||
|
|
||||||
|
def p2s_ready(ctx):
|
||||||
|
return bool(ctx['p2s_enabled'] and ctx['p2s_address_pool'] and ctx['local_fqdn'])
|
||||||
|
|
||||||
|
|
||||||
|
def wg_ready(ctx):
|
||||||
|
return bool(ctx['wg_enabled'] and ctx['wg_address'])
|
||||||
|
|
||||||
|
|
||||||
|
def _jinja_env():
|
||||||
|
return jinja2.Environment(
|
||||||
|
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
|
||||||
|
keep_trailing_newline=True,
|
||||||
|
undefined=jinja2.StrictUndefined,
|
||||||
|
autoescape=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render(env, ctx, template_name, dest, mode):
|
||||||
|
"""Render a template, writing only when the content changes."""
|
||||||
|
content = env.get_template(template_name).render(ctx)
|
||||||
|
dest = pathlib.Path(dest)
|
||||||
|
if dest.exists() and dest.read_text(encoding='utf-8') == content:
|
||||||
|
os.chmod(dest, mode)
|
||||||
|
return False
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest.write_text(content, encoding='utf-8')
|
||||||
|
os.chmod(dest, mode)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def existing_peers(path):
|
||||||
|
"""Return the [Peer] sections of an existing WireGuard config.
|
||||||
|
|
||||||
|
Peers are added by the operator by hand, so re-rendering the [Interface]
|
||||||
|
section must not discard them.
|
||||||
|
"""
|
||||||
|
path = pathlib.Path(path)
|
||||||
|
if not path.is_file():
|
||||||
|
return ''
|
||||||
|
lines = path.read_text(encoding='utf-8').splitlines(keepends=True)
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
if line.strip().lower() == '[peer]':
|
||||||
|
return ''.join(lines[index:])
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def render_wg_conf(env, ctx, dest=WG_CONF, mode=0o600):
|
||||||
|
"""Render wg0.conf, carrying any operator-defined peers across."""
|
||||||
|
content = env.get_template('wg0.conf.j2').render(ctx)
|
||||||
|
peers = existing_peers(dest)
|
||||||
|
if peers:
|
||||||
|
content = content.rstrip('\n') + '\n\n' + peers
|
||||||
|
|
||||||
|
dest = pathlib.Path(dest)
|
||||||
|
if dest.exists() and dest.read_text(encoding='utf-8') == content:
|
||||||
|
os.chmod(dest, mode)
|
||||||
|
return False
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest.write_text(content, encoding='utf-8')
|
||||||
|
os.chmod(dest, mode)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def remove(path):
|
||||||
|
"""Remove a generated file. Returns True when something was removed."""
|
||||||
|
path = pathlib.Path(path)
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def install_file(src, dest, mode):
|
||||||
|
"""Copy src to dest when the content differs. Returns True on change."""
|
||||||
|
src, dest = pathlib.Path(src), pathlib.Path(dest)
|
||||||
|
if not src.is_file():
|
||||||
|
return False
|
||||||
|
if dest.exists() and dest.read_bytes() == src.read_bytes():
|
||||||
|
os.chmod(dest, mode)
|
||||||
|
return False
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(src, dest)
|
||||||
|
os.chmod(dest, mode)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _simple_ca(*args):
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(SIMPLE_CA), *args],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
fail(f'simple-ca {" ".join(args)} failed: {result.stderr.strip()}')
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def provision_pki(ctx):
|
||||||
|
"""Create the CA and server certificate when the PKI directory is empty."""
|
||||||
|
PKI_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.chmod(PKI_DIR, 0o700)
|
||||||
|
|
||||||
|
if any(PKI_DIR.iterdir()):
|
||||||
|
return
|
||||||
|
if not ctx['p2s_ca_name']:
|
||||||
|
warn('p2s.ca_name is empty, not creating a local CA')
|
||||||
|
return
|
||||||
|
if not SIMPLE_CA.is_file():
|
||||||
|
fail(f'{SIMPLE_CA} is missing')
|
||||||
|
|
||||||
|
_simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name'])
|
||||||
|
_simple_ca('make-cert', '--ca-dir', str(PKI_DIR),
|
||||||
|
ctx['local_fqdn'], ctx['local_fqdn'])
|
||||||
|
|
||||||
|
|
||||||
|
def distribute_pki(ctx):
|
||||||
|
"""Copy PKI material to each consumer. Returns True when anything changed."""
|
||||||
|
label = ctx['local_fqdn'].split('.', 1)[0] if ctx['local_fqdn'] else ''
|
||||||
|
bundle = PKI_DIR / 'ca_bundle.pem'
|
||||||
|
ca_src = bundle if bundle.is_file() else PKI_DIR / 'ca_cert.pem'
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
changed |= install_file(ca_src, CA_CERT_DEST, 0o644)
|
||||||
|
if label:
|
||||||
|
changed |= install_file(PKI_DIR / f'{label}_cert.pem', SERVER_CERT, 0o644)
|
||||||
|
changed |= install_file(PKI_DIR / f'{label}_key.pem', SERVER_KEY, 0o600)
|
||||||
|
changed |= install_file(PKI_DIR / 'crl.pem', CRL_DEST, 0o644)
|
||||||
|
|
||||||
|
if install_file(PKI_DIR / 'ca_cert.pem', TRUST_ANCHOR, 0o644):
|
||||||
|
changed = True
|
||||||
|
if shutil.which('update-ca-certificates'):
|
||||||
|
subprocess.run(['update-ca-certificates'],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def configure(cfg=None, local_id_override=None):
|
||||||
|
"""Render configuration files and provision PKI.
|
||||||
|
|
||||||
|
Returns (ctx, changed) where changed is a set naming what needs reloading:
|
||||||
|
'swanctl', 'resolved', 'wireguard'.
|
||||||
|
"""
|
||||||
|
if cfg is None:
|
||||||
|
cfg = load_config()
|
||||||
|
|
||||||
|
# platform = none: configuration is deferred. Files are installed and
|
||||||
|
# nothing is rendered, provisioned or removed until a platform is chosen.
|
||||||
|
if deferred(cfg):
|
||||||
|
return None, set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate(cfg)
|
||||||
|
ctx = build_context(cfg, local_id_override=local_id_override)
|
||||||
|
except ConfigError as exc:
|
||||||
|
fail(str(exc))
|
||||||
|
|
||||||
|
changed = set()
|
||||||
|
|
||||||
|
if p2s_ready(ctx):
|
||||||
|
provision_pki(ctx)
|
||||||
|
if distribute_pki(ctx):
|
||||||
|
changed.add('swanctl')
|
||||||
|
|
||||||
|
env = _jinja_env()
|
||||||
|
|
||||||
|
if s2s_ready(ctx):
|
||||||
|
if render(env, ctx, 'remote-site.conf.j2', REMOTE_SITE, 0o600):
|
||||||
|
changed.add('swanctl')
|
||||||
|
elif remove(REMOTE_SITE):
|
||||||
|
changed.add('swanctl')
|
||||||
|
|
||||||
|
if p2s_ready(ctx):
|
||||||
|
if render(env, ctx, 'road-warrior.conf.j2', ROAD_WARRIOR, 0o600):
|
||||||
|
changed.add('swanctl')
|
||||||
|
if render(env, ctx, 'p2s-forwarder.conf.j2', P2S_FORWARDER, 0o644):
|
||||||
|
changed.add('resolved')
|
||||||
|
else:
|
||||||
|
if remove(ROAD_WARRIOR):
|
||||||
|
changed.add('swanctl')
|
||||||
|
if remove(P2S_FORWARDER):
|
||||||
|
changed.add('resolved')
|
||||||
|
|
||||||
|
if wg_ready(ctx):
|
||||||
|
if render_wg_conf(env, ctx):
|
||||||
|
changed.add('wireguard')
|
||||||
|
elif remove(WG_CONF):
|
||||||
|
changed.add('wireguard')
|
||||||
|
|
||||||
|
return ctx, changed
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Platform modules for vpn-router.
|
||||||
|
|
||||||
|
A platform module supplies the values and behaviour that only make sense on one
|
||||||
|
kind of host. The core is complete without any of them; a module only adds
|
||||||
|
what the core deliberately refuses to know about, such as a provider's DNS
|
||||||
|
resolver address.
|
||||||
|
|
||||||
|
Modules are discovered by listing this package, so adding a platform means
|
||||||
|
adding one file here. Nothing in the core has to be edited.
|
||||||
|
|
||||||
|
A module may define any of:
|
||||||
|
|
||||||
|
EXTRA_P2S_DNS str, added to the road-warrior pool 'dns' list
|
||||||
|
EXTRA_LOCAL_TS str, added to 'local_ts' on both connections
|
||||||
|
apply(ctx) called once the system has been configured, to apply
|
||||||
|
platform-specific state. Must be idempotent. ctx is the
|
||||||
|
template context, carrying wan_iface, int_iface,
|
||||||
|
local_cidrs and everything else derived from the
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
Anything a module does not define takes its default, so a module that only
|
||||||
|
needs one constant is one line long.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import pkgutil
|
||||||
|
|
||||||
|
DEFAULT = 'generic'
|
||||||
|
|
||||||
|
#: Context keys a module may contribute, with the value used when it does not.
|
||||||
|
CONTEXT_DEFAULTS = {
|
||||||
|
'EXTRA_P2S_DNS': '',
|
||||||
|
'EXTRA_LOCAL_TS': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def available():
|
||||||
|
"""Names of the installed platform modules."""
|
||||||
|
return sorted(module.name for module in pkgutil.iter_modules(__path__))
|
||||||
|
|
||||||
|
|
||||||
|
def load(name):
|
||||||
|
"""Import a platform module by name.
|
||||||
|
|
||||||
|
Raises ModuleNotFoundError when no such module is installed.
|
||||||
|
"""
|
||||||
|
return importlib.import_module(f'{__name__}.{name}')
|
||||||
|
|
||||||
|
|
||||||
|
def context(module):
|
||||||
|
"""Read a module's context contributions, applying defaults."""
|
||||||
|
return {key: getattr(module, key, default)
|
||||||
|
for key, default in CONTEXT_DEFAULTS.items()}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Microsoft Azure.
|
||||||
|
|
||||||
|
No platform-specific configuration is currently required.
|
||||||
|
|
||||||
|
The Azure platform resolver 168.63.129.16 is deliberately *not* advertised to
|
||||||
|
road-warrior clients and *not* added to the traffic selectors. Clients are
|
||||||
|
given the router itself as their DNS server, and the router forwards through
|
||||||
|
its own systemd-resolved via the p2s-forwarder drop-in. That resolves
|
||||||
|
Azure-internal names without routing the wireserver address through the tunnel
|
||||||
|
and without a second resolver in the pool.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Google Cloud Platform.
|
||||||
|
|
||||||
|
No platform-specific configuration is currently required.
|
||||||
|
|
||||||
|
As on Azure, road-warrior clients are given the router itself as their DNS
|
||||||
|
server and the router forwards through its own systemd-resolved, so the
|
||||||
|
metadata resolver does not belong in the pool or in the traffic selectors.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Generic platform: no additions.
|
||||||
|
|
||||||
|
The default, and the correct choice on any host that needs nothing
|
||||||
|
platform-specific. It defines nothing, which is the point: the core is
|
||||||
|
complete on its own.
|
||||||
|
"""
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
network:
|
|
||||||
version: 2
|
|
||||||
ethernets:
|
|
||||||
eth1:
|
|
||||||
routes:
|
|
||||||
- to: {{ local_subnet }}
|
|
||||||
via: {{ router_int_gateway_ip }}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
LOCAL_ADDRS="{{ local_addrs }}"
|
|
||||||
LOCAL_FQDN="{{ local_fqdn }}"
|
|
||||||
LOCAL_ID_MODE="{{ local_id_mode }}"
|
|
||||||
LOCAL_CIDRS="{{ local_cidrs }}"
|
|
||||||
LOCAL_SUBNET="{{ local_subnet }}"
|
|
||||||
REMOTE_ADDRS="{{ remote_addrs }}"
|
|
||||||
REMOTE_ID="{{ remote_id }}"
|
|
||||||
REMOTE_CIDRS="{{ remote_cidrs }}"
|
|
||||||
ROUTER_INT_GATEWAY_IP="{{ router_int_gateway_ip }}"
|
|
||||||
P2S_ADDRESS_POOL="{{ p2s_address_pool }}"
|
|
||||||
P2S_SERVER_NAME="{{ p2s_server_name }}"
|
|
||||||
WG_ENABLED="{{ wg_enabled }}"
|
|
||||||
WG_ADDRESS="{{ wg_address }}"
|
|
||||||
WG_LISTEN_PORT="{{ wg_listen_port }}"
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
[Resolve]
|
|
||||||
DNSStubListenerExtra={{ local_addrs }}
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Example vpn-router configuration.
|
||||||
|
#
|
||||||
|
# This file is documentation. The live configuration is
|
||||||
|
# /etc/vpn-router/vpn-router.conf, which is generated on first install and
|
||||||
|
# owned by the operator afterwards. Copy settings from here as needed.
|
||||||
|
#
|
||||||
|
# Format: INI. Lists are comma-separated, booleans are true/false, an empty
|
||||||
|
# value means unset, and a _b64 suffix means the value is base64-encoded.
|
||||||
|
#
|
||||||
|
# Comments start with # or ; and may follow a value on the same line. That
|
||||||
|
# means # and ; cannot appear inside a value - which is why the pre-shared
|
||||||
|
# key, the one setting that can hold arbitrary text, is base64-encoded.
|
||||||
|
#
|
||||||
|
# Apply any change with: systemctl restart vpn-router-setup
|
||||||
|
#
|
||||||
|
# Every feature is optional. A file with nothing filled in configures nothing
|
||||||
|
# and leaves the host reachable and unchanged.
|
||||||
|
|
||||||
|
[general]
|
||||||
|
# Platform: none, generic, azure or gcp.
|
||||||
|
# none - configuration is deferred. Files are installed and nothing on
|
||||||
|
# this machine is changed. Set a real platform when ready.
|
||||||
|
# generic - configure, with no platform-specific additions.
|
||||||
|
platform = none
|
||||||
|
|
||||||
|
# How much of the network configuration you supply, and therefore how much is
|
||||||
|
# read from the system:
|
||||||
|
# manual - interface names, int_addr and int_gateway_ip are all given
|
||||||
|
# below. Nothing is detected.
|
||||||
|
# interfaces - the two interface names are given; int_addr and
|
||||||
|
# int_gateway_ip are read from them.
|
||||||
|
# auto - nothing is given. The interfaces are identified from the
|
||||||
|
# routing table. Convenient, and able to get it wrong.
|
||||||
|
mode = manual
|
||||||
|
|
||||||
|
[interfaces]
|
||||||
|
# External faces the untrusted network; internal faces the protected one.
|
||||||
|
# Required in manual and interfaces mode; worked out for you in auto mode.
|
||||||
|
external =
|
||||||
|
internal =
|
||||||
|
|
||||||
|
[wan]
|
||||||
|
# This router's fully-qualified domain name.
|
||||||
|
local_fqdn =
|
||||||
|
# Source of the local IKE identity: fqdn | public_ip | internal_ip
|
||||||
|
# fqdn - use local_fqdn (must match what the peer expects)
|
||||||
|
# public_ip - resolve local_fqdn at boot and use the address
|
||||||
|
# internal_ip - use the address on the internal interface
|
||||||
|
local_id_mode = fqdn
|
||||||
|
|
||||||
|
[local]
|
||||||
|
# Local subnets advertised into the tunnel, comma-separated.
|
||||||
|
cidrs =
|
||||||
|
# This host's own address on the internal network, and the next hop on that
|
||||||
|
# side for the subnets above. Both are required in manual mode and read from
|
||||||
|
# the internal interface in the other two.
|
||||||
|
int_addr =
|
||||||
|
int_gateway_ip =
|
||||||
|
|
||||||
|
[remote]
|
||||||
|
# Remote gateway address(es) or FQDN, comma-separated.
|
||||||
|
addrs =
|
||||||
|
# Remote IKE identity, without a leading '@'.
|
||||||
|
id =
|
||||||
|
# Remote subnets reachable through the tunnel, comma-separated.
|
||||||
|
cidrs =
|
||||||
|
# Pre-shared key, base64-encoded: printf %s "$PSK" | base64 -w0
|
||||||
|
psk_b64 =
|
||||||
|
# Path to a file containing the raw PSK. Takes precedence over psk_b64.
|
||||||
|
psk_file =
|
||||||
|
|
||||||
|
[p2s]
|
||||||
|
# Road-warrior access.
|
||||||
|
enabled = false
|
||||||
|
# Address pool handed to road-warrior clients.
|
||||||
|
address_pool =
|
||||||
|
# CN for the CA created when /etc/vpn-router/pki is empty.
|
||||||
|
ca_name = VPN Router CA
|
||||||
|
|
||||||
|
[wireguard]
|
||||||
|
enabled = false
|
||||||
|
# Address and prefix length for wg0, for example 192.168.200.1/24.
|
||||||
|
address =
|
||||||
|
listen_port = 51820
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[Resolve]
|
||||||
|
{% for address in local_addr_list -%}
|
||||||
|
DNSStubListenerExtra={{ address }}
|
||||||
|
{% endfor -%}
|
||||||
+1
-1
@@ -21,7 +21,7 @@ connections {
|
|||||||
children {
|
children {
|
||||||
site2site {
|
site2site {
|
||||||
mode = tunnel
|
mode = tunnel
|
||||||
local_ts = {{ local_cidrs }}
|
local_ts = {{ local_ts }}
|
||||||
remote_ts = {{ remote_cidrs }}
|
remote_ts = {{ remote_cidrs }}
|
||||||
esp_proposals = aes256-sha256,aes256-sha1
|
esp_proposals = aes256-sha256,aes256-sha1
|
||||||
life_time = 3600
|
life_time = 3600
|
||||||
+2
-2
@@ -21,7 +21,7 @@ connections {
|
|||||||
children {
|
children {
|
||||||
road-warrior {
|
road-warrior {
|
||||||
mode = tunnel
|
mode = tunnel
|
||||||
local_ts = {{ local_cidrs }}
|
local_ts = {{ local_ts }}
|
||||||
remote_ts = dynamic
|
remote_ts = dynamic
|
||||||
esp_proposals = aes256-sha256,aes256-sha1
|
esp_proposals = aes256-sha256,aes256-sha1
|
||||||
life_time = 3600
|
life_time = 3600
|
||||||
@@ -38,6 +38,6 @@ connections {
|
|||||||
pools {
|
pools {
|
||||||
rw-pool {
|
rw-pool {
|
||||||
addrs = {{ p2s_address_pool }}
|
addrs = {{ p2s_address_pool }}
|
||||||
dns = {{ local_addrs }}
|
dns = {{ p2s_dns }}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Azure example
|
||||||
|
|
||||||
|
Deploys `vpn-router` on an Azure VM: a two-NIC router (external/WAN and internal/protected),
|
||||||
|
a demo protected `workload` subnet routed through it, and cloud-init that installs and
|
||||||
|
configures the package on first boot. See the repository's top-level [README](../../README.md)
|
||||||
|
for what the package itself does.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- An Azure subscription and `az login` (or another form of Azure credentials Terraform can pick
|
||||||
|
up).
|
||||||
|
- An SSH key pair for `admin_ssh_public_key`.
|
||||||
|
- A build of the `vpn-router` package published to the repository at `repo_url` (default: the
|
||||||
|
project's Gitea Debian registry). See [../../debian-package](../../debian-package) for
|
||||||
|
`build.sh`/`publish.sh`.
|
||||||
|
- That repository must allow anonymous reads, since the router pulls the package with no
|
||||||
|
credentials configured. On Gitea this means the `debian` package registry under the
|
||||||
|
`slawek` account is set to public/anonymous-read; `publish.sh` still authenticates to
|
||||||
|
upload, only reads are anonymous. This is a one-time change made on the Gitea side, outside
|
||||||
|
this repository.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp terraform.tfvars.example terraform.tfvars
|
||||||
|
$EDITOR terraform.tfvars
|
||||||
|
export TF_VAR_psk='change-me' # keep secrets out of tracked files
|
||||||
|
```
|
||||||
|
|
||||||
|
`platform = azure` and `mode = auto` are hard-coded into the cloud-init template call in
|
||||||
|
`router.tf`: the router has exactly two NICs, the external one carries the default route (it is
|
||||||
|
marked `primary` and holds the public IP), which is exactly the case `mode = auto` is designed
|
||||||
|
to detect reliably - see the top-level README's "What mode decides" section.
|
||||||
|
|
||||||
|
## Verifying
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh <admin_username>@$(terraform output -raw router_public_ip)
|
||||||
|
systemctl status vpn-router-setup
|
||||||
|
swanctl --list-sas
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `deploy_workload_vm = true` and re-apply to add a VM on the `workload` subnet (no public
|
||||||
|
IP - reach it via the router or Azure Bastion) for confirming that its traffic to `remote_cidrs`
|
||||||
|
actually flows through the router.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
resource "azurerm_resource_group" "this" {
|
||||||
|
name = "rg-${var.name}"
|
||||||
|
location = var.location
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "azurerm_virtual_network" "this" {
|
||||||
|
name = "vnet-${var.name}"
|
||||||
|
resource_group_name = azurerm_resource_group.this.name
|
||||||
|
location = azurerm_resource_group.this.location
|
||||||
|
address_space = [var.vnet_address_space]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "azurerm_subnet" "ext" {
|
||||||
|
name = "ext"
|
||||||
|
resource_group_name = azurerm_resource_group.this.name
|
||||||
|
virtual_network_name = azurerm_virtual_network.this.name
|
||||||
|
address_prefixes = [var.ext_subnet_cidr]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "azurerm_subnet" "int" {
|
||||||
|
name = "int"
|
||||||
|
resource_group_name = azurerm_resource_group.this.name
|
||||||
|
virtual_network_name = azurerm_virtual_network.this.name
|
||||||
|
address_prefixes = [var.int_subnet_cidr]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "azurerm_subnet" "workload" {
|
||||||
|
name = "workload"
|
||||||
|
resource_group_name = azurerm_resource_group.this.name
|
||||||
|
virtual_network_name = azurerm_virtual_network.this.name
|
||||||
|
address_prefixes = [var.workload_subnet_cidr]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "azurerm_dns_a_record" "router_ext" {
|
||||||
|
count = var.dns_zone_id != null ? 1 : 0
|
||||||
|
|
||||||
|
name = trimsuffix(var.local_fqdn, ".${split("/", var.dns_zone_id)[length(split("/", var.dns_zone_id)) - 1]}")
|
||||||
|
zone_name = split("/", var.dns_zone_id)[length(split("/", var.dns_zone_id)) - 1]
|
||||||
|
resource_group_name = split("/", var.dns_zone_id)[4]
|
||||||
|
ttl = 300
|
||||||
|
records = [azurerm_public_ip.ext.ip_address]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "azurerm_network_security_group" "router_ext" {
|
||||||
|
name = "nsg-${var.name}-ext"
|
||||||
|
resource_group_name = azurerm_resource_group.this.name
|
||||||
|
location = azurerm_resource_group.this.location
|
||||||
|
|
||||||
|
security_rule {
|
||||||
|
name = "Allow-SSH-TCP-22"
|
||||||
|
priority = 100
|
||||||
|
direction = "Inbound"
|
||||||
|
access = "Allow"
|
||||||
|
protocol = "Tcp"
|
||||||
|
source_port_range = "*"
|
||||||
|
destination_port_range = "22"
|
||||||
|
source_address_prefix = "*"
|
||||||
|
destination_address_prefix = "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
security_rule {
|
||||||
|
name = "Allow-IKE-UDP-500"
|
||||||
|
priority = 110
|
||||||
|
direction = "Inbound"
|
||||||
|
access = "Allow"
|
||||||
|
protocol = "Udp"
|
||||||
|
source_port_range = "*"
|
||||||
|
destination_port_range = "500"
|
||||||
|
source_address_prefix = "*"
|
||||||
|
destination_address_prefix = "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
security_rule {
|
||||||
|
name = "Allow-IPsec-NAT-T-UDP-4500"
|
||||||
|
priority = 120
|
||||||
|
direction = "Inbound"
|
||||||
|
access = "Allow"
|
||||||
|
protocol = "Udp"
|
||||||
|
source_port_range = "*"
|
||||||
|
destination_port_range = "4500"
|
||||||
|
source_address_prefix = "*"
|
||||||
|
destination_address_prefix = "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic "security_rule" {
|
||||||
|
for_each = var.wireguard_enabled ? [1] : []
|
||||||
|
content {
|
||||||
|
name = "Allow-WireGuard-UDP"
|
||||||
|
priority = 130
|
||||||
|
direction = "Inbound"
|
||||||
|
access = "Allow"
|
||||||
|
protocol = "Udp"
|
||||||
|
source_port_range = "*"
|
||||||
|
destination_port_range = tostring(var.wireguard_listen_port)
|
||||||
|
source_address_prefix = "*"
|
||||||
|
destination_address_prefix = "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "azurerm_network_interface_security_group_association" "router_ext" {
|
||||||
|
network_interface_id = azurerm_network_interface.ext.id
|
||||||
|
network_security_group_id = azurerm_network_security_group.router_ext.id
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
output "router_public_ip" {
|
||||||
|
description = "Public IP address of the router VM."
|
||||||
|
value = azurerm_public_ip.ext.ip_address
|
||||||
|
}
|
||||||
|
|
||||||
|
output "router_internal_ip" {
|
||||||
|
description = "Private IP address of the router's internal NIC - the next hop to use in routes configured outside this Terraform run (another route table, or an on-prem device) that need to reach remote_cidrs through this router."
|
||||||
|
value = azurerm_network_interface.int.private_ip_address
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
provider "azurerm" {
|
||||||
|
features {}
|
||||||
|
|
||||||
|
subscription_id = var.subscription_id
|
||||||
|
tenant_id = var.tenant_id != "" ? var.tenant_id : null
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
data "http" "repo_gpg_key" {
|
||||||
|
url = "${var.repo_url}/repository.key"
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
supply_pki = var.ca_cert_file != ""
|
||||||
|
|
||||||
|
cloud_init_vars = {
|
||||||
|
hostname = var.name
|
||||||
|
fqdn = var.local_fqdn
|
||||||
|
repo_url = var.repo_url
|
||||||
|
repo_gpg_key = data.http.repo_gpg_key.response_body
|
||||||
|
ubuntu_codename = var.ubuntu_codename
|
||||||
|
platform = "azure"
|
||||||
|
mode = "auto"
|
||||||
|
external_interface = ""
|
||||||
|
internal_interface = ""
|
||||||
|
local_id_mode = var.local_id_mode
|
||||||
|
local_cidrs = var.local_cidrs
|
||||||
|
int_addr = ""
|
||||||
|
int_gateway_ip = ""
|
||||||
|
remote_addrs = var.remote_addrs
|
||||||
|
remote_id = var.remote_id
|
||||||
|
remote_cidrs = var.remote_cidrs
|
||||||
|
psk_b64 = base64encode(var.psk)
|
||||||
|
p2s_enabled = var.p2s_enabled
|
||||||
|
p2s_address_pool = var.p2s_address_pool
|
||||||
|
p2s_ca_name = "VPN Router CA"
|
||||||
|
wg_enabled = var.wireguard_enabled
|
||||||
|
wg_address = var.wireguard_address
|
||||||
|
wg_listen_port = var.wireguard_listen_port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "azurerm_public_ip" "ext" {
|
||||||
|
name = "pip-${var.name}-ext"
|
||||||
|
resource_group_name = azurerm_resource_group.this.name
|
||||||
|
location = azurerm_resource_group.this.location
|
||||||
|
allocation_method = "Static"
|
||||||
|
sku = "Standard"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "azurerm_network_interface" "ext" {
|
||||||
|
name = "${var.name}-ext-nic"
|
||||||
|
resource_group_name = azurerm_resource_group.this.name
|
||||||
|
location = azurerm_resource_group.this.location
|
||||||
|
ip_forwarding_enabled = true
|
||||||
|
|
||||||
|
ip_configuration {
|
||||||
|
name = "ext"
|
||||||
|
subnet_id = azurerm_subnet.ext.id
|
||||||
|
private_ip_address_allocation = "Dynamic"
|
||||||
|
public_ip_address_id = azurerm_public_ip.ext.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "azurerm_network_interface" "int" {
|
||||||
|
name = "${var.name}-int-nic"
|
||||||
|
resource_group_name = azurerm_resource_group.this.name
|
||||||
|
location = azurerm_resource_group.this.location
|
||||||
|
ip_forwarding_enabled = true
|
||||||
|
|
||||||
|
ip_configuration {
|
||||||
|
name = "int"
|
||||||
|
subnet_id = azurerm_subnet.int.id
|
||||||
|
private_ip_address_allocation = "Dynamic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "azurerm_linux_virtual_machine" "router" {
|
||||||
|
name = var.name
|
||||||
|
resource_group_name = azurerm_resource_group.this.name
|
||||||
|
location = azurerm_resource_group.this.location
|
||||||
|
size = var.vm_size
|
||||||
|
admin_username = var.admin_username
|
||||||
|
admin_password = var.admin_password != "" ? var.admin_password : null
|
||||||
|
disable_password_authentication = var.admin_password == ""
|
||||||
|
|
||||||
|
network_interface_ids = [
|
||||||
|
azurerm_network_interface.ext.id,
|
||||||
|
azurerm_network_interface.int.id,
|
||||||
|
]
|
||||||
|
|
||||||
|
admin_ssh_key {
|
||||||
|
username = var.admin_username
|
||||||
|
public_key = var.admin_ssh_public_key
|
||||||
|
}
|
||||||
|
|
||||||
|
os_disk {
|
||||||
|
caching = "ReadWrite"
|
||||||
|
storage_account_type = "StandardSSD_LRS"
|
||||||
|
}
|
||||||
|
|
||||||
|
source_image_reference {
|
||||||
|
publisher = "Canonical"
|
||||||
|
offer = "ubuntu-24_04-lts"
|
||||||
|
sku = "server"
|
||||||
|
version = "latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
custom_data = base64encode(
|
||||||
|
local.supply_pki
|
||||||
|
? templatefile("${path.module}/../cloud-init-with-pki.yaml.tpl", merge(local.cloud_init_vars, {
|
||||||
|
label = split(".", var.local_fqdn)[0]
|
||||||
|
ca_cert = file(var.ca_cert_file)
|
||||||
|
server_cert = file(var.server_cert_file)
|
||||||
|
server_key = file(var.server_key_file)
|
||||||
|
}))
|
||||||
|
: templatefile("${path.module}/../cloud-init.yaml.tpl", local.cloud_init_vars)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
locals {
|
||||||
|
remote_cidr_list = [for c in split(",", var.remote_cidrs) : trimspace(c) if trimspace(c) != ""]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "azurerm_route_table" "router" {
|
||||||
|
name = "rt-${var.name}"
|
||||||
|
resource_group_name = azurerm_resource_group.this.name
|
||||||
|
location = azurerm_resource_group.this.location
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "azurerm_route" "to_remote" {
|
||||||
|
for_each = toset(local.remote_cidr_list)
|
||||||
|
|
||||||
|
name = "to-${replace(each.value, "/", "-")}"
|
||||||
|
resource_group_name = azurerm_resource_group.this.name
|
||||||
|
route_table_name = azurerm_route_table.router.name
|
||||||
|
address_prefix = each.value
|
||||||
|
next_hop_type = "VirtualAppliance"
|
||||||
|
next_hop_in_ip_address = azurerm_network_interface.int.private_ip_address
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "azurerm_subnet_route_table_association" "workload" {
|
||||||
|
subnet_id = azurerm_subnet.workload.id
|
||||||
|
route_table_id = azurerm_route_table.router.id
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Copy to terraform.tfvars (or a git-ignored *.auto.tfvars) and adjust.
|
||||||
|
# Keep secrets - psk here - out of any tracked file: set them with
|
||||||
|
# TF_VAR_psk or in a git-ignored *.auto.tfvars instead.
|
||||||
|
|
||||||
|
subscription_id = "00000000-0000-0000-0000-000000000000"
|
||||||
|
location = "polandcentral"
|
||||||
|
name = "vpn-router-example"
|
||||||
|
admin_ssh_public_key = "ssh-ed25519 AAAA... you@example.com"
|
||||||
|
|
||||||
|
local_fqdn = "router.example.com"
|
||||||
|
local_cidrs = "10.0.3.0/24"
|
||||||
|
|
||||||
|
remote_addrs = "peer.example.net"
|
||||||
|
remote_id = "peer.example.net"
|
||||||
|
remote_cidrs = "192.168.0.0/24"
|
||||||
|
|
||||||
|
p2s_enabled = false
|
||||||
|
wireguard_enabled = false
|
||||||
|
deploy_workload_vm = false
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
variable "subscription_id" {
|
||||||
|
description = "Azure subscription ID to deploy into."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "tenant_id" {
|
||||||
|
description = "Azure AD tenant ID. Leave empty to use the tenant of the credentials Terraform is running with."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "location" {
|
||||||
|
description = "Azure region to deploy into."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "name" {
|
||||||
|
description = "Base name used to derive resource names."
|
||||||
|
type = string
|
||||||
|
default = "vpn-router-example"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "admin_ssh_public_key" {
|
||||||
|
description = "SSH public key installed for the admin_username on both VMs."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "admin_username" {
|
||||||
|
description = "Admin username on both VMs."
|
||||||
|
type = string
|
||||||
|
default = "azureuser"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "admin_password" {
|
||||||
|
description = "Admin password for the router VM. SSH key auth is always configured; leaving this empty additionally disables password authentication, setting it enables password auth alongside the key."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_size" {
|
||||||
|
description = "VM size for the router."
|
||||||
|
type = string
|
||||||
|
default = "Standard_B2ls_v2"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "local_fqdn" {
|
||||||
|
description = "FQDN of the router, used as the road-warrior/IKE identity and, when dns_zone_id is set, as the name of the A record created for it."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "dns_zone_id" {
|
||||||
|
description = "Resource ID of an existing Azure DNS zone to create local_fqdn's A record in, pointing at the router's public IP. local_fqdn must be a name within that zone. Leave null to skip - local_fqdn is then just a label with nothing making it resolve."
|
||||||
|
type = string
|
||||||
|
nullable = true
|
||||||
|
default = null
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "local_id_mode" {
|
||||||
|
description = "IKE local identity source: fqdn, public_ip or internal_ip."
|
||||||
|
type = string
|
||||||
|
default = "fqdn"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "local_cidrs" {
|
||||||
|
description = "Local subnet CIDR(s) advertised into the site-to-site tunnel. Should include the workload subnet CIDR."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "remote_addrs" {
|
||||||
|
description = "Remote gateway address(es) or FQDN for the site-to-site tunnel."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "remote_id" {
|
||||||
|
description = "Remote peer's IKE identity, without a leading @."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "remote_cidrs" {
|
||||||
|
description = "Remote subnet CIDR(s) reachable through the site-to-site tunnel."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "psk" {
|
||||||
|
description = "Pre-shared key for the site-to-site IKEv2 tunnel."
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "p2s_enabled" {
|
||||||
|
description = "Enable road-warrior (P2S) access."
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "p2s_address_pool" {
|
||||||
|
description = "CIDR block assigned to road-warrior clients."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ca_cert_file" {
|
||||||
|
description = "Path to an existing CA certificate PEM to supply instead of letting the package generate one. Leave empty to auto-generate."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "server_cert_file" {
|
||||||
|
description = "Path to an existing server certificate PEM, paired with ca_cert_file."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "server_key_file" {
|
||||||
|
description = "Path to an existing server private key PEM, paired with ca_cert_file."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "wireguard_enabled" {
|
||||||
|
description = "Enable the WireGuard endpoint."
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "wireguard_address" {
|
||||||
|
description = "Address and prefix length for the wg0 interface."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "wireguard_listen_port" {
|
||||||
|
description = "UDP port WireGuard listens on."
|
||||||
|
type = number
|
||||||
|
default = 51820
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "deploy_workload_vm" {
|
||||||
|
description = "Deploy a bare VM on the workload subnet, for manually verifying routing through the router."
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "repo_url" {
|
||||||
|
description = "Base URL of the Debian package repository the router pulls vpn-router from."
|
||||||
|
type = string
|
||||||
|
default = "https://gitea.koszewscy.waw.pl/api/packages/slawek/debian"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ubuntu_codename" {
|
||||||
|
description = "Ubuntu release codename of the router VM's image, used to select the apt repo component."
|
||||||
|
type = string
|
||||||
|
default = "noble"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vnet_address_space" {
|
||||||
|
description = "Address space of the example VNet."
|
||||||
|
type = string
|
||||||
|
default = "10.0.0.0/16"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ext_subnet_cidr" {
|
||||||
|
description = "CIDR of the router's external (WAN-facing) subnet."
|
||||||
|
type = string
|
||||||
|
default = "10.0.1.0/24"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "int_subnet_cidr" {
|
||||||
|
description = "CIDR of the router's internal (protected-network-facing) subnet."
|
||||||
|
type = string
|
||||||
|
default = "10.0.2.0/24"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "workload_subnet_cidr" {
|
||||||
|
description = "CIDR of the demo protected workload subnet, routed through the router."
|
||||||
|
type = string
|
||||||
|
default = "10.0.3.0/24"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
terraform {
|
||||||
|
required_version = ">= 1.9"
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
azurerm = {
|
||||||
|
source = "hashicorp/azurerm"
|
||||||
|
version = ">= 4.0, < 5.0"
|
||||||
|
}
|
||||||
|
http = {
|
||||||
|
source = "hashicorp/http"
|
||||||
|
version = ">= 3.4, < 4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Bare VM on the workload subnet, for manually verifying that its traffic to
|
||||||
|
# remote_cidrs flows through the router. Off by default so a plain apply
|
||||||
|
# stays to just the router.
|
||||||
|
|
||||||
|
resource "azurerm_network_interface" "workload" {
|
||||||
|
count = var.deploy_workload_vm ? 1 : 0
|
||||||
|
name = "${var.name}-workload-nic"
|
||||||
|
resource_group_name = azurerm_resource_group.this.name
|
||||||
|
location = azurerm_resource_group.this.location
|
||||||
|
|
||||||
|
ip_configuration {
|
||||||
|
name = "workload"
|
||||||
|
subnet_id = azurerm_subnet.workload.id
|
||||||
|
private_ip_address_allocation = "Dynamic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "azurerm_linux_virtual_machine" "workload" {
|
||||||
|
count = var.deploy_workload_vm ? 1 : 0
|
||||||
|
name = "${var.name}-workload"
|
||||||
|
resource_group_name = azurerm_resource_group.this.name
|
||||||
|
location = azurerm_resource_group.this.location
|
||||||
|
size = var.vm_size
|
||||||
|
admin_username = var.admin_username
|
||||||
|
disable_password_authentication = true
|
||||||
|
network_interface_ids = [azurerm_network_interface.workload[0].id]
|
||||||
|
|
||||||
|
admin_ssh_key {
|
||||||
|
username = var.admin_username
|
||||||
|
public_key = var.admin_ssh_public_key
|
||||||
|
}
|
||||||
|
|
||||||
|
os_disk {
|
||||||
|
caching = "ReadWrite"
|
||||||
|
storage_account_type = "StandardSSD_LRS"
|
||||||
|
}
|
||||||
|
|
||||||
|
source_image_reference {
|
||||||
|
publisher = "Canonical"
|
||||||
|
offer = "ubuntu-24_04-lts"
|
||||||
|
sku = "server"
|
||||||
|
version = "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#cloud-config
|
||||||
|
#
|
||||||
|
# Example: install and configure vpn-router during first boot, supplying an
|
||||||
|
# existing CA and server certificate instead of letting the package generate
|
||||||
|
# one. See cloud-init.yaml.tpl for the variant that generates its own CA.
|
||||||
|
#
|
||||||
|
# Template variables are Terraform templatefile() placeholders. Adapt or drop
|
||||||
|
# them for whatever renders this file. <label> is the first component of the
|
||||||
|
# FQDN, for example "router" in router.example.com.
|
||||||
|
|
||||||
|
hostname: ${hostname}
|
||||||
|
fqdn: ${fqdn}
|
||||||
|
manage_etc_hosts: false
|
||||||
|
|
||||||
|
apt:
|
||||||
|
sources:
|
||||||
|
vpn-router:
|
||||||
|
source: "deb [signed-by=/etc/apt/keyrings/vpn-router.asc] ${repo_url} ${ubuntu_codename} main"
|
||||||
|
key: |
|
||||||
|
${indent(8, trimspace(repo_gpg_key))}
|
||||||
|
|
||||||
|
write_files:
|
||||||
|
# The configuration file. Created here before the package is installed, so
|
||||||
|
# postinst leaves it alone.
|
||||||
|
- path: /etc/vpn-router/vpn-router.conf
|
||||||
|
permissions: '0600'
|
||||||
|
owner: root:root
|
||||||
|
content: |
|
||||||
|
[general]
|
||||||
|
platform = ${platform}
|
||||||
|
mode = ${mode}
|
||||||
|
|
||||||
|
[interfaces]
|
||||||
|
external = ${external_interface}
|
||||||
|
internal = ${internal_interface}
|
||||||
|
|
||||||
|
[wan]
|
||||||
|
local_fqdn = ${fqdn}
|
||||||
|
local_id_mode = ${local_id_mode}
|
||||||
|
|
||||||
|
[local]
|
||||||
|
cidrs = ${local_cidrs}
|
||||||
|
int_addr = ${int_addr}
|
||||||
|
int_gateway_ip = ${int_gateway_ip}
|
||||||
|
|
||||||
|
[remote]
|
||||||
|
addrs = ${remote_addrs}
|
||||||
|
id = ${remote_id}
|
||||||
|
cidrs = ${remote_cidrs}
|
||||||
|
psk_b64 = ${psk_b64}
|
||||||
|
|
||||||
|
[p2s]
|
||||||
|
enabled = ${p2s_enabled}
|
||||||
|
address_pool = ${p2s_address_pool}
|
||||||
|
ca_name = ${p2s_ca_name}
|
||||||
|
|
||||||
|
[wireguard]
|
||||||
|
enabled = ${wg_enabled}
|
||||||
|
address = ${wg_address}
|
||||||
|
listen_port = ${wg_listen_port}
|
||||||
|
|
||||||
|
- path: /etc/vpn-router/pki/ca_cert.pem
|
||||||
|
permissions: '0644'
|
||||||
|
owner: root:root
|
||||||
|
content: |
|
||||||
|
${indent(6, trimspace(ca_cert))}
|
||||||
|
- path: /etc/vpn-router/pki/${label}_cert.pem
|
||||||
|
permissions: '0644'
|
||||||
|
owner: root:root
|
||||||
|
content: |
|
||||||
|
${indent(6, trimspace(server_cert))}
|
||||||
|
- path: /etc/vpn-router/pki/${label}_key.pem
|
||||||
|
permissions: '0600'
|
||||||
|
owner: root:root
|
||||||
|
content: |
|
||||||
|
${indent(6, trimspace(server_key))}
|
||||||
|
|
||||||
|
package_update: true
|
||||||
|
|
||||||
|
packages:
|
||||||
|
- vpn-router
|
||||||
|
|
||||||
|
# Nothing further is required: installing the package starts
|
||||||
|
# vpn-router-setup, which applies the configuration written above.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#cloud-config
|
||||||
|
#
|
||||||
|
# Example: install and configure vpn-router during first boot.
|
||||||
|
#
|
||||||
|
# This is one way to reach the state described in README.md, not an interface
|
||||||
|
# the package depends on. It writes the configuration file directly, which
|
||||||
|
# keeps everything in one place and works the same whether or not debconf is
|
||||||
|
# involved.
|
||||||
|
#
|
||||||
|
# Template variables are Terraform templatefile() placeholders. Adapt or drop
|
||||||
|
# them for whatever renders this file.
|
||||||
|
#
|
||||||
|
# This variant lets the package generate its own CA on first boot. See
|
||||||
|
# cloud-init-with-pki.yaml.tpl for the variant that supplies existing PKI
|
||||||
|
# material instead.
|
||||||
|
|
||||||
|
hostname: ${hostname}
|
||||||
|
fqdn: ${fqdn}
|
||||||
|
manage_etc_hosts: false
|
||||||
|
|
||||||
|
apt:
|
||||||
|
sources:
|
||||||
|
vpn-router:
|
||||||
|
source: "deb [signed-by=/etc/apt/keyrings/vpn-router.asc] ${repo_url} ${ubuntu_codename} main"
|
||||||
|
key: |
|
||||||
|
${indent(8, trimspace(repo_gpg_key))}
|
||||||
|
|
||||||
|
write_files:
|
||||||
|
# The configuration file. Created here before the package is installed, so
|
||||||
|
# postinst leaves it alone.
|
||||||
|
- path: /etc/vpn-router/vpn-router.conf
|
||||||
|
permissions: '0600'
|
||||||
|
owner: root:root
|
||||||
|
content: |
|
||||||
|
[general]
|
||||||
|
platform = ${platform}
|
||||||
|
mode = ${mode}
|
||||||
|
|
||||||
|
[interfaces]
|
||||||
|
external = ${external_interface}
|
||||||
|
internal = ${internal_interface}
|
||||||
|
|
||||||
|
[wan]
|
||||||
|
local_fqdn = ${fqdn}
|
||||||
|
local_id_mode = ${local_id_mode}
|
||||||
|
|
||||||
|
[local]
|
||||||
|
cidrs = ${local_cidrs}
|
||||||
|
int_addr = ${int_addr}
|
||||||
|
int_gateway_ip = ${int_gateway_ip}
|
||||||
|
|
||||||
|
[remote]
|
||||||
|
addrs = ${remote_addrs}
|
||||||
|
id = ${remote_id}
|
||||||
|
cidrs = ${remote_cidrs}
|
||||||
|
psk_b64 = ${psk_b64}
|
||||||
|
|
||||||
|
[p2s]
|
||||||
|
enabled = ${p2s_enabled}
|
||||||
|
address_pool = ${p2s_address_pool}
|
||||||
|
ca_name = ${p2s_ca_name}
|
||||||
|
|
||||||
|
[wireguard]
|
||||||
|
enabled = ${wg_enabled}
|
||||||
|
address = ${wg_address}
|
||||||
|
listen_port = ${wg_listen_port}
|
||||||
|
|
||||||
|
package_update: true
|
||||||
|
|
||||||
|
packages:
|
||||||
|
- vpn-router
|
||||||
|
|
||||||
|
# Nothing further is required: installing the package starts
|
||||||
|
# vpn-router-setup, which applies the configuration written above.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# GCP example
|
||||||
|
|
||||||
|
Deploys `vpn-router` on a GCE instance: a two-NIC router (external/WAN and internal/protected,
|
||||||
|
each its own VPC network), a demo protected `workload` subnet in the internal network routed
|
||||||
|
through it, and cloud-init that installs and configures the package on first boot. See the
|
||||||
|
repository's top-level [README](../../README.md) for what the package itself does, and
|
||||||
|
[../azure/](../azure/) for the equivalent Azure example - the two share the same design.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A GCP project and credentials Terraform can pick up (`gcloud auth application-default login`
|
||||||
|
or a service account).
|
||||||
|
- An SSH key pair for `admin_ssh_public_key`.
|
||||||
|
- A build of the `vpn-router` package published to the repository at `repo_url` (default: the
|
||||||
|
project's Gitea Debian registry). See [../../debian-package](../../debian-package) for
|
||||||
|
`build.sh`/`publish.sh`. That repository must allow anonymous reads, since the router pulls
|
||||||
|
the package with no credentials configured.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp terraform.tfvars.example terraform.tfvars
|
||||||
|
$EDITOR terraform.tfvars
|
||||||
|
export TF_VAR_psk='change-me' # keep secrets out of tracked files
|
||||||
|
```
|
||||||
|
|
||||||
|
`platform = gcp` and `mode = auto` are hard-coded into the cloud-init template call in
|
||||||
|
`router.tf`: the router has exactly two NICs, and on GCP the system default route always goes
|
||||||
|
out NIC0 (the external one here) while a secondary NIC only reaches its own subnet unless
|
||||||
|
policy-routed - exactly the case `mode = auto` is designed to detect reliably. See the
|
||||||
|
top-level README's "What mode decides" section.
|
||||||
|
|
||||||
|
Unlike the Azure example, `ext` and `internal` are two separate VPC networks (GCP instances
|
||||||
|
attach one NIC per network), with `int` and `workload` as two subnets inside the same
|
||||||
|
`internal` network. GCP routes apply network-wide rather than per-subnet, so `routes.tf`
|
||||||
|
needs no separate subnet association - the route in `internal` already covers both subnets.
|
||||||
|
|
||||||
|
## Verifying
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh <admin_username>@$(terraform output -raw router_public_ip)
|
||||||
|
systemctl status vpn-router-setup
|
||||||
|
swanctl --list-sas
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `deploy_workload_vm = true` and re-apply to add a VM on the `workload` subnet (no public
|
||||||
|
IP - reach it via the router or IAP) for confirming that its traffic to `remote_cidrs` actually
|
||||||
|
flows through the router.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
resource "google_dns_record_set" "router_ext" {
|
||||||
|
count = var.dns_managed_zone != "" ? 1 : 0
|
||||||
|
|
||||||
|
name = "${var.local_fqdn}."
|
||||||
|
type = "A"
|
||||||
|
ttl = 300
|
||||||
|
managed_zone = var.dns_managed_zone
|
||||||
|
project = var.dns_project != "" ? var.dns_project : var.project_id
|
||||||
|
rrdatas = [google_compute_address.ext.address]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_compute_network" "ext" {
|
||||||
|
name = "${var.name}-ext"
|
||||||
|
auto_create_subnetworks = false
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_compute_subnetwork" "ext" {
|
||||||
|
name = "${var.name}-ext"
|
||||||
|
network = google_compute_network.ext.id
|
||||||
|
region = var.region
|
||||||
|
ip_cidr_range = var.ext_subnet_cidr
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_compute_network" "internal" {
|
||||||
|
name = "${var.name}-int"
|
||||||
|
auto_create_subnetworks = false
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_compute_subnetwork" "int" {
|
||||||
|
name = "${var.name}-int"
|
||||||
|
network = google_compute_network.internal.id
|
||||||
|
region = var.region
|
||||||
|
ip_cidr_range = var.int_subnet_cidr
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_compute_subnetwork" "workload" {
|
||||||
|
name = "${var.name}-workload"
|
||||||
|
network = google_compute_network.internal.id
|
||||||
|
region = var.region
|
||||||
|
ip_cidr_range = var.workload_subnet_cidr
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_compute_firewall" "router_ext" {
|
||||||
|
name = "${var.name}-ext-allow-router"
|
||||||
|
network = google_compute_network.ext.name
|
||||||
|
|
||||||
|
allow {
|
||||||
|
protocol = "tcp"
|
||||||
|
ports = ["22"]
|
||||||
|
}
|
||||||
|
|
||||||
|
allow {
|
||||||
|
protocol = "udp"
|
||||||
|
ports = ["500", "4500"]
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic "allow" {
|
||||||
|
for_each = var.wireguard_enabled ? [1] : []
|
||||||
|
content {
|
||||||
|
protocol = "udp"
|
||||||
|
ports = [tostring(var.wireguard_listen_port)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source_ranges = ["0.0.0.0/0"]
|
||||||
|
target_tags = ["${var.name}-router"]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_compute_firewall" "internal_allow_all" {
|
||||||
|
name = "${var.name}-int-allow-internal"
|
||||||
|
network = google_compute_network.internal.name
|
||||||
|
|
||||||
|
allow {
|
||||||
|
protocol = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
source_ranges = [var.int_subnet_cidr, var.workload_subnet_cidr]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
output "router_public_ip" {
|
||||||
|
description = "Public IP address of the router VM."
|
||||||
|
value = google_compute_address.ext.address
|
||||||
|
}
|
||||||
|
|
||||||
|
output "router_internal_ip" {
|
||||||
|
description = "Private IP address of the router's internal NIC - the next hop to use in routes configured outside this Terraform run (another route, or an on-prem device) that need to reach remote_cidrs through this router."
|
||||||
|
value = google_compute_instance.router.network_interface[1].network_ip
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
provider "google" {
|
||||||
|
project = var.project_id
|
||||||
|
region = var.region
|
||||||
|
zone = var.zone
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
data "http" "repo_gpg_key" {
|
||||||
|
url = "${var.repo_url}/repository.key"
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
supply_pki = var.ca_cert_file != ""
|
||||||
|
|
||||||
|
cloud_init_vars = {
|
||||||
|
hostname = var.name
|
||||||
|
fqdn = var.local_fqdn
|
||||||
|
repo_url = var.repo_url
|
||||||
|
repo_gpg_key = data.http.repo_gpg_key.response_body
|
||||||
|
ubuntu_codename = var.ubuntu_codename
|
||||||
|
platform = "gcp"
|
||||||
|
mode = "auto"
|
||||||
|
external_interface = ""
|
||||||
|
internal_interface = ""
|
||||||
|
local_id_mode = var.local_id_mode
|
||||||
|
local_cidrs = var.local_cidrs
|
||||||
|
int_addr = ""
|
||||||
|
int_gateway_ip = ""
|
||||||
|
remote_addrs = var.remote_addrs
|
||||||
|
remote_id = var.remote_id
|
||||||
|
remote_cidrs = var.remote_cidrs
|
||||||
|
psk_b64 = base64encode(var.psk)
|
||||||
|
p2s_enabled = var.p2s_enabled
|
||||||
|
p2s_address_pool = var.p2s_address_pool
|
||||||
|
p2s_ca_name = "VPN Router CA"
|
||||||
|
wg_enabled = var.wireguard_enabled
|
||||||
|
wg_address = var.wireguard_address
|
||||||
|
wg_listen_port = var.wireguard_listen_port
|
||||||
|
}
|
||||||
|
|
||||||
|
cloud_init_rendered = local.supply_pki ? templatefile("${path.module}/../cloud-init-with-pki.yaml.tpl", merge(local.cloud_init_vars, {
|
||||||
|
label = split(".", var.local_fqdn)[0]
|
||||||
|
ca_cert = file(var.ca_cert_file)
|
||||||
|
server_cert = file(var.server_cert_file)
|
||||||
|
server_key = file(var.server_key_file)
|
||||||
|
})) : templatefile("${path.module}/../cloud-init.yaml.tpl", local.cloud_init_vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_compute_address" "ext" {
|
||||||
|
name = "${var.name}-ext"
|
||||||
|
region = var.region
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_compute_instance" "router" {
|
||||||
|
name = var.name
|
||||||
|
machine_type = var.machine_type
|
||||||
|
zone = var.zone
|
||||||
|
tags = ["${var.name}-router"]
|
||||||
|
|
||||||
|
can_ip_forward = true
|
||||||
|
|
||||||
|
boot_disk {
|
||||||
|
initialize_params {
|
||||||
|
image = "ubuntu-os-cloud/ubuntu-2404-lts-amd64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
network_interface {
|
||||||
|
network = google_compute_network.ext.id
|
||||||
|
subnetwork = google_compute_subnetwork.ext.id
|
||||||
|
|
||||||
|
access_config {
|
||||||
|
nat_ip = google_compute_address.ext.address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
network_interface {
|
||||||
|
network = google_compute_network.internal.id
|
||||||
|
subnetwork = google_compute_subnetwork.int.id
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
ssh-keys = "${var.admin_username}:${var.admin_ssh_public_key}"
|
||||||
|
user-data = local.cloud_init_rendered
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
locals {
|
||||||
|
remote_cidr_list = [for c in split(",", var.remote_cidrs) : trimspace(c) if trimspace(c) != ""]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_compute_route" "to_remote" {
|
||||||
|
for_each = toset(local.remote_cidr_list)
|
||||||
|
|
||||||
|
name = "${var.name}-to-${replace(replace(each.value, "/", "-"), ".", "-")}"
|
||||||
|
network = google_compute_network.internal.name
|
||||||
|
dest_range = each.value
|
||||||
|
next_hop_instance = google_compute_instance.router.self_link
|
||||||
|
priority = 100
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Copy to terraform.tfvars (or a git-ignored *.auto.tfvars) and adjust.
|
||||||
|
# Keep secrets - psk here - out of any tracked file: set them with
|
||||||
|
# TF_VAR_psk or in a git-ignored *.auto.tfvars instead.
|
||||||
|
|
||||||
|
project_id = "my-gcp-project"
|
||||||
|
region = "europe-central2"
|
||||||
|
zone = "europe-central2-a"
|
||||||
|
name = "vpn-router-example"
|
||||||
|
admin_ssh_public_key = "ssh-ed25519 AAAA... you@example.com"
|
||||||
|
|
||||||
|
local_fqdn = "router.example.com"
|
||||||
|
local_cidrs = "10.0.3.0/24"
|
||||||
|
|
||||||
|
remote_addrs = "peer.example.net"
|
||||||
|
remote_id = "peer.example.net"
|
||||||
|
remote_cidrs = "192.168.0.0/24"
|
||||||
|
|
||||||
|
p2s_enabled = false
|
||||||
|
wireguard_enabled = false
|
||||||
|
deploy_workload_vm = false
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
variable "project_id" {
|
||||||
|
description = "GCP project to deploy into."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "region" {
|
||||||
|
description = "GCP region to deploy into."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "zone" {
|
||||||
|
description = "GCP zone to deploy the VMs into."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "name" {
|
||||||
|
description = "Base name used to derive resource names."
|
||||||
|
type = string
|
||||||
|
default = "vpn-router-example"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "admin_username" {
|
||||||
|
description = "Admin username created on both VMs via SSH key metadata."
|
||||||
|
type = string
|
||||||
|
default = "vpnrouter"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "admin_ssh_public_key" {
|
||||||
|
description = "SSH public key installed for admin_username on both VMs."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "machine_type" {
|
||||||
|
description = "Machine type for the router."
|
||||||
|
type = string
|
||||||
|
default = "e2-small"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "local_fqdn" {
|
||||||
|
description = "FQDN of the router, used as the road-warrior/IKE identity and, when dns_managed_zone is set, as the name of the A record created for it."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "dns_managed_zone" {
|
||||||
|
description = "Name of an existing Cloud DNS managed zone to create local_fqdn's A record in, pointing at the router's public IP. local_fqdn must be a name within that zone's DNS name. Leave empty to skip - local_fqdn is then just a label with nothing making it resolve."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "dns_project" {
|
||||||
|
description = "Project that owns dns_managed_zone. Leave empty to use project_id."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "local_id_mode" {
|
||||||
|
description = "IKE local identity source: fqdn, public_ip or internal_ip."
|
||||||
|
type = string
|
||||||
|
default = "fqdn"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "local_cidrs" {
|
||||||
|
description = "Local subnet CIDR(s) advertised into the site-to-site tunnel. Should include the workload subnet CIDR."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "remote_addrs" {
|
||||||
|
description = "Remote gateway address(es) or FQDN for the site-to-site tunnel."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "remote_id" {
|
||||||
|
description = "Remote peer's IKE identity, without a leading @."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "remote_cidrs" {
|
||||||
|
description = "Remote subnet CIDR(s) reachable through the site-to-site tunnel."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "psk" {
|
||||||
|
description = "Pre-shared key for the site-to-site IKEv2 tunnel."
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "p2s_enabled" {
|
||||||
|
description = "Enable road-warrior (P2S) access."
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "p2s_address_pool" {
|
||||||
|
description = "CIDR block assigned to road-warrior clients."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ca_cert_file" {
|
||||||
|
description = "Path to an existing CA certificate PEM to supply instead of letting the package generate one. Leave empty to auto-generate."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "server_cert_file" {
|
||||||
|
description = "Path to an existing server certificate PEM, paired with ca_cert_file."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "server_key_file" {
|
||||||
|
description = "Path to an existing server private key PEM, paired with ca_cert_file."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "wireguard_enabled" {
|
||||||
|
description = "Enable the WireGuard endpoint."
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "wireguard_address" {
|
||||||
|
description = "Address and prefix length for the wg0 interface."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "wireguard_listen_port" {
|
||||||
|
description = "UDP port WireGuard listens on."
|
||||||
|
type = number
|
||||||
|
default = 51820
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "deploy_workload_vm" {
|
||||||
|
description = "Deploy a bare VM on the workload subnet, for manually verifying routing through the router."
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "repo_url" {
|
||||||
|
description = "Base URL of the Debian package repository the router pulls vpn-router from."
|
||||||
|
type = string
|
||||||
|
default = "https://gitea.koszewscy.waw.pl/api/packages/slawek/debian"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ubuntu_codename" {
|
||||||
|
description = "Ubuntu release codename of the router VM's image, used to select the apt repo component."
|
||||||
|
type = string
|
||||||
|
default = "noble"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ext_subnet_cidr" {
|
||||||
|
description = "CIDR of the router's external (WAN-facing) subnet, in its own VPC network."
|
||||||
|
type = string
|
||||||
|
default = "10.0.1.0/24"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "int_subnet_cidr" {
|
||||||
|
description = "CIDR of the router's internal (protected-network-facing) subnet, in its own VPC network."
|
||||||
|
type = string
|
||||||
|
default = "10.0.2.0/24"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "workload_subnet_cidr" {
|
||||||
|
description = "CIDR of the demo protected workload subnet, in the same VPC network as int_subnet_cidr and routed through the router."
|
||||||
|
type = string
|
||||||
|
default = "10.0.3.0/24"
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
terraform {
|
||||||
|
required_version = ">= 1.9"
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
google = {
|
||||||
|
source = "hashicorp/google"
|
||||||
|
version = ">= 7.0, < 8.0"
|
||||||
|
}
|
||||||
|
http = {
|
||||||
|
source = "hashicorp/http"
|
||||||
|
version = ">= 3.4, < 4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Bare VM on the workload subnet, for manually verifying that its traffic to
|
||||||
|
# remote_cidrs flows through the router. Off by default so a plain apply
|
||||||
|
# stays to just the router.
|
||||||
|
|
||||||
|
resource "google_compute_instance" "workload" {
|
||||||
|
count = var.deploy_workload_vm ? 1 : 0
|
||||||
|
name = "${var.name}-workload"
|
||||||
|
machine_type = var.machine_type
|
||||||
|
zone = var.zone
|
||||||
|
|
||||||
|
boot_disk {
|
||||||
|
initialize_params {
|
||||||
|
image = "ubuntu-os-cloud/ubuntu-2404-lts-amd64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
network_interface {
|
||||||
|
network = google_compute_network.internal.id
|
||||||
|
subnetwork = google_compute_subnetwork.workload.id
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
ssh-keys = "${var.admin_username}:${var.admin_ssh_public_key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+77
@@ -0,0 +1,77 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# Example: unattended install and configuration of vpn-router on an existing
|
||||||
|
# host. Run as root. Adjust the values below, or set them in the environment.
|
||||||
|
#
|
||||||
|
# This is the same end state as the cloud-init example and as configuring the
|
||||||
|
# router by hand; only the way the file is written differs.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DEB="${DEB:-./vpn-router_1.0.0-1_all.deb}"
|
||||||
|
|
||||||
|
PLATFORM="${PLATFORM:-generic}"
|
||||||
|
MODE="${MODE:-manual}"
|
||||||
|
EXTERNAL_INTERFACE="${EXTERNAL_INTERFACE:-eth0}"
|
||||||
|
INTERNAL_INTERFACE="${INTERNAL_INTERFACE:-eth1}"
|
||||||
|
LOCAL_FQDN="${LOCAL_FQDN:-router.example.com}"
|
||||||
|
LOCAL_ID_MODE="${LOCAL_ID_MODE:-fqdn}"
|
||||||
|
LOCAL_CIDRS="${LOCAL_CIDRS:-10.0.0.0/24}"
|
||||||
|
INT_ADDR="${INT_ADDR:-10.1.1.4}"
|
||||||
|
INT_GATEWAY_IP="${INT_GATEWAY_IP:-10.1.1.1}"
|
||||||
|
REMOTE_ADDRS="${REMOTE_ADDRS:-peer.example.net}"
|
||||||
|
REMOTE_ID="${REMOTE_ID:-peer.example.net}"
|
||||||
|
REMOTE_CIDRS="${REMOTE_CIDRS:-192.168.0.0/24}"
|
||||||
|
PSK="${PSK:-change-me}"
|
||||||
|
P2S_ENABLED="${P2S_ENABLED:-false}"
|
||||||
|
P2S_ADDRESS_POOL="${P2S_ADDRESS_POOL:-172.16.0.0/24}"
|
||||||
|
P2S_CA_NAME="${P2S_CA_NAME:-VPN Router CA}"
|
||||||
|
WG_ENABLED="${WG_ENABLED:-false}"
|
||||||
|
WG_ADDRESS="${WG_ADDRESS:-}"
|
||||||
|
WG_LISTEN_PORT="${WG_LISTEN_PORT:-51820}"
|
||||||
|
|
||||||
|
# Write the configuration before installing, so postinst leaves it alone.
|
||||||
|
install -d -m 0755 /etc/vpn-router
|
||||||
|
umask 077
|
||||||
|
cat > /etc/vpn-router/vpn-router.conf <<EOF
|
||||||
|
[general]
|
||||||
|
platform = ${PLATFORM}
|
||||||
|
mode = ${MODE}
|
||||||
|
|
||||||
|
[interfaces]
|
||||||
|
external = ${EXTERNAL_INTERFACE}
|
||||||
|
internal = ${INTERNAL_INTERFACE}
|
||||||
|
|
||||||
|
[wan]
|
||||||
|
local_fqdn = ${LOCAL_FQDN}
|
||||||
|
local_id_mode = ${LOCAL_ID_MODE}
|
||||||
|
|
||||||
|
[local]
|
||||||
|
cidrs = ${LOCAL_CIDRS}
|
||||||
|
int_addr = ${INT_ADDR}
|
||||||
|
int_gateway_ip = ${INT_GATEWAY_IP}
|
||||||
|
|
||||||
|
[remote]
|
||||||
|
addrs = ${REMOTE_ADDRS}
|
||||||
|
id = ${REMOTE_ID}
|
||||||
|
cidrs = ${REMOTE_CIDRS}
|
||||||
|
psk_b64 = $(printf %s "$PSK" | base64 -w0)
|
||||||
|
|
||||||
|
[p2s]
|
||||||
|
enabled = ${P2S_ENABLED}
|
||||||
|
address_pool = ${P2S_ADDRESS_POOL}
|
||||||
|
ca_name = ${P2S_CA_NAME}
|
||||||
|
|
||||||
|
[wireguard]
|
||||||
|
enabled = ${WG_ENABLED}
|
||||||
|
address = ${WG_ADDRESS}
|
||||||
|
listen_port = ${WG_LISTEN_PORT}
|
||||||
|
EOF
|
||||||
|
umask 022
|
||||||
|
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y "$DEB"
|
||||||
|
|
||||||
|
# Installing starts vpn-router-setup, which applies the file above. Restart it
|
||||||
|
# explicitly after any later edit.
|
||||||
|
systemctl restart vpn-router-setup
|
||||||
|
systemctl --no-pager status vpn-router-setup
|
||||||
Reference in New Issue
Block a user