Add cloud-router configuration templates and scripts

- Introduced debian templates for cloud-router configuration parameters.
- Added simple-ca.sh script for managing a minimal Certificate Authority (CA) for IKEv2 PKI.
- Created sysctl configuration to enable IP forwarding and adjust rp_filter settings.
- Implemented configure script to render configuration files using Jinja2 templates.
- Added simple-ca script for generating CA and certificates.
- Created Jinja2 templates for various configuration files including netplan, strongSwan, and WireGuard.
- Implemented UFW rules setup for IPsec and WireGuard.
- Added support for road-warrior and site-to-site VPN configurations.
This commit is contained in:
2026-05-27 00:33:07 +02:00
parent db78066d5c
commit 3c665c2b6c
22 changed files with 1676 additions and 0 deletions
+369
View File
@@ -0,0 +1,369 @@
# Plan: Debian Package Source for linux-cloud-router
## Context
The `azure-router` Terraform module provisions a Linux router VM using Cloud Init. Cloud Init
installs packages, writes config files (strongSwan swanctl, WireGuard, netplan, sysctl, UFW
rules), and runs a one-shot setup service. All site-specific values (IPs, FQDNs, CIDRs, PSK)
are injected at provision time via Terraform's `templatefile()`.
The Debian package replicates the same outcome on Ubuntu 22.04, 24.04, and 26.04 hosts
(exact supported version list TBD — compatibility and feature availability must be verified
across all three; see Further Considerations). Site-specific values
are collected via **debconf** interactive prompts at `dpkg install` time. A `postinst` script
reads the debconf answers and generates all derived config files.
---
## Decisions
- Site config: **debconf** interactive prompts at install time
- P2S certificates: managed with `simple-ca.sh` directly
- Networking: Ubuntu only — netplan (not `/etc/network/interfaces.d/`)
- Build approach: full Debian source package (`dpkg-buildpackage` + debhelper ≥13)
- PKI library: `simple-ca.sh` (OpenSSL-based); currently RSA-4096, ECDSA/Ed25519 support
is a planned future enhancement to the script itself — not a concern for this package
- `strongswan-pki` is **not** a dependency; `openssl` covers all PKI needs (including PKCS#12
which `pki` tool cannot produce)
---
## Source Tree Layout
```
linux-cloud-router/
├── debian/
│ ├── control
│ ├── changelog
│ ├── rules
│ ├── copyright
│ ├── install
│ ├── dirs
│ ├── templates ← debconf question definitions
│ ├── config ← pre-install debconf prompts
│ ├── postinst ← generates all config files, enables services
│ └── prerm ← stops/disables services
└── src/
├── etc/
│ ├── sysctl.d/
│ │ └── 99-cloud-router.conf
│ ├── xdg/nvim/
│ │ └── init.vim
│ └── systemd/system/
│ └── cloud-router-setup.service
└── usr/local/
├── lib/
│ └── simple-ca.sh
└── sbin/
└── cloud-router-setup adapted from azure-router-setup
```
---
## Phase 0 — Package Availability Research
Use Apple's `container` CLI to launch one-shot Ubuntu containers for each target release:
```sh
PKGS="strongswan-swanctl charon-systemd libstrongswan-extra-plugins \
libcharon-extra-plugins wireguard-tools ufw openssl debconf \
debhelper netplan.io"
for RELEASE in 22.04 24.04 26.04; do
echo "=== ubuntu:$RELEASE ==="
container run --rm ubuntu:$RELEASE \
sh -c "apt-get update -qq && apt-cache policy $PKGS"
done
```
`container` is also the primary build environment: mount the source tree and run
`dpkg-buildpackage` inside the target Ubuntu container, keeping the macOS host clean.
Record the candidate version for each package per release. Use the results to:
- Confirm all packages exist in the default repos (no PPAs needed)
- Verify `debhelper` version supports compat 14 (requires ≥13.10); fall back to compat 13
if 22.04 support is required and its debhelper is too old
- Confirm strongSwan package names are consistent across releases
- Finalise the supported Ubuntu version list
---
## Phase 1 — Package Scaffold
1. `debian/control` — package name `cloud-router`, short description, `Build-Depends` and `Depends` (see below)
2. `debian/changelog` — initial `1.0.0-1` entry
3. `debian/rules` — minimal `dh $@` (debhelper)
4. `debian/copyright` — MIT (matches simple-ca.sh)
**`debian/control` Build-Depends:**
```
debhelper-compat (= 14)
```
**`debian/control` Depends:**
```
strongswan-swanctl, charon-systemd, libstrongswan-extra-plugins, libcharon-extra-plugins,
wireguard-tools, ufw, debconf, openssl
```
---
## Phase 2 — Static Source Files
6. `src/etc/sysctl.d/99-cloud-router.conf`
```
net.ipv4.ip_forward = 1
net.ipv4.conf.all.rp_filter = 0
net.ipv4.conf.default.rp_filter = 0
```
(identical content to cloud-init)
7. `src/etc/xdg/nvim/init.vim` — exact neovim settings from `router-cloud-init.tpl`
8. `src/etc/systemd/system/cloud-router-setup.service`
Renamed from `azure-router-setup.service`. Same content:
```ini
[Unit]
Description=Linux Cloud Router Setup
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/cloud-router-setup
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
```
---
## Phase 3 — `cloud-router-setup` Script
9. Adapt `azure-router-setup` bash script from `router-cloud-init.tpl`:
- Add `source /etc/default/cloud-router` at the top
- Replace all Terraform-interpolated values (`${local_subnet}`, `${remote_cidrs}`,
`${wg_listen_port}`, `${p2s_address_pool}`, `${local_addrs}`, `${fqdn}`) with the
corresponding variable names sourced from `/etc/default/cloud-router`
- Rename script to `cloud-router-setup`; install to `src/usr/local/sbin/cloud-router-setup`
- The script remains idempotent (checks for existing UFW rule markers before inserting)
- WireGuard key generation and WireGuard UFW rules are guarded by `if [ "$WG_ENABLED" = "true" ]`
---
## Phase 4 — PKI Library
10. `src/usr/local/lib/simple-ca.sh` — Bash function module providing
`make_ca`, `make_cert`, `make_pfx`. Source it in any shell session with
`source /usr/local/lib/simple-ca.sh`; set `SIMPLE_CA_DIR=/etc/cloud-router/pki`
before use.
11. `cloud-router-sync-pki` — standalone script that copies PKI output to swanctl's
expected locations:
- `/etc/cloud-router/pki/ca_bundle.pem` → `/etc/swanctl/x509ca/ca.pem`
- `/etc/cloud-router/pki/{server}_cert.pem` → `/etc/swanctl/x509/server.pem`
- `/etc/cloud-router/pki/{server}_key.pem` → `/etc/swanctl/private/server.key`
The server cert name is read from `P2S_SERVER_NAME` in `/etc/default/cloud-router`
(defaults to the hostname component of `LOCAL_FQDN`).
---
## Phase 5 — Debconf Templates and Config Script
13. `debian/templates` — one `Template:` block per configurable value:
| Question | Type | Default |
|---|---|---|
| `cloud-router/local_addrs` | string | |
| `cloud-router/local_fqdn` | string | |
| `cloud-router/local_id_mode` | select | `fqdn` (choices: fqdn, public_ip, internal_ip) |
| `cloud-router/local_cidrs` | string | |
| `cloud-router/remote_addrs` | string | |
| `cloud-router/remote_id` | string | |
| `cloud-router/psk` | password | |
| `cloud-router/remote_cidrs` | string | |
| `cloud-router/router_int_gateway_ip` | string | |
| `cloud-router/p2s_address_pool` | string | |
| `cloud-router/wg_enabled` | boolean | `false` |
| `cloud-router/wg_address` | string | `10.0.1.1/24` *(shown only when wg_enabled=true)* |
| `cloud-router/wg_listen_port` | string | `51820` *(shown only when wg_enabled=true)* |
14. `debian/config` — pre-install shell script:
- Sources `/usr/share/debconf/confmodule`
- Calls `db_input` for each question in priority order
- Shows `wg_address` and `wg_listen_port` conditionally: `db_get cloud-router/wg_enabled; if [ "$RET" = "true" ]`
---
## Phase 6 — Maintainer Scripts
15. `debian/postinst` (triggered on `configure`):
1. `db_get` all debconf answers
2. Derive `LOCAL_SUBNET` = first element of `LOCAL_CIDRS`
3. Derive `P2S_SERVER_NAME` = hostname part of `LOCAL_FQDN`
4. Write `/etc/default/cloud-router` (mode 0644, root:root) — non-secret config:
```sh
LOCAL_ADDRS="..."
LOCAL_FQDN="..."
LOCAL_ID_MODE="..."
LOCAL_CIDRS="..."
LOCAL_SUBNET="..."
REMOTE_ADDRS="..."
REMOTE_ID="..."
REMOTE_CIDRS="..."
ROUTER_INT_GATEWAY_IP="..."
P2S_ADDRESS_POOL="..."
P2S_SERVER_NAME="..."
WG_ENABLED="..."
WG_ADDRESS="..." # only relevant when WG_ENABLED=true
WG_LISTEN_PORT="..." # only relevant when WG_ENABLED=true
```
5. Generate `/etc/swanctl/conf.d/remote-site.conf` (mode 0600, root:root) via bash heredoc —
includes both the `connections {}` block and a `secrets { ike-remote-site { secret = "$PSK" } }`
block; the file must be 0600 because it contains the PSK
6. Generate `/etc/swanctl/conf.d/road-warrior.conf` via heredoc
7. Generate `/etc/systemd/resolved.conf.d/p2s-forwarder.conf` via heredoc
8. If `WG_ENABLED=true`: generate `/etc/wireguard/wg0.conf` via heredoc
9. Generate `/etc/netplan/90-cloud-router.yaml` via heredoc
10. `sysctl --system`
11. `netplan apply`
12. `systemctl daemon-reload`
13. `systemctl enable --now cloud-router-setup.service`
14. `ufw allow 22/tcp && ufw --force enable && ufw reload`
15. `systemctl enable --now strongswan`
16. `systemctl restart systemd-resolved`
16. `debian/prerm` (on `remove`):
- `systemctl disable --now cloud-router-setup.service`
- `systemctl disable --now strongswan`
---
## Phase 7 — Install Manifest
17. `debian/install` — maps `src/` tree entries to absolute filesystem paths, e.g.:
```
src/etc/sysctl.d/99-cloud-router.conf etc/sysctl.d/
src/etc/xdg/nvim/init.vim etc/xdg/nvim/
src/etc/systemd/system/cloud-router-setup.service etc/systemd/system/
src/usr/local/sbin/cloud-router-setup usr/local/sbin/
src/usr/local/lib/simple-ca.sh usr/local/lib/
```
18. `debian/dirs` — directories that must pre-exist:
```
etc/cloud-router
etc/cloud-router/pki
etc/wireguard
etc/swanctl/conf.d
etc/swanctl/x509ca
etc/swanctl/x509
etc/swanctl/private
etc/systemd/resolved.conf.d
```
---
## Phase 8 — Cloud-Init YAML
The `.deb` is delivered via a private apt repository (URL/GPG key TBD). The cloud-init
user-data pre-seeds all debconf answers before the package is installed, so `postinst` runs
fully unattended on first boot.
Template file `cloud-router-cloud-init.yaml.tpl` (rendered by `templatefile()` at provision
time, Terraform variable names shown as `${…}`):
```yaml
#cloud-config
apt:
sources:
cloud-router:
source: "deb [signed-by=/etc/apt/keyrings/cloud-router.gpg] ${repo_url} ${ubuntu_codename} main"
key: |
${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
```
Notes:
- `debconf_selections` runs before `packages` in cloud-init's module order, so answers are
in place before `postinst` executes
- `DEBIAN_FRONTEND` does not need to be set explicitly; debconf detects the pre-seeded
answers and skips interactive prompts automatically
- `${ubuntu_codename}` (e.g. `noble`) must be passed as a Terraform variable or derived
from the VM image; `${repo_gpg_key}` is the ASCII-armoured public key of the signing key
- The template replaces the existing `router-cloud-init.tpl` in the `azure-router` module
---
## Verification
1. `dpkg-buildpackage -us -uc -b` from `linux-cloud-router/` completes without error; `.deb` produced
2. `dpkg -i cloud-router_1.0.0-1_all.deb` on a fresh VM for each target Ubuntu release
(22.04, 24.04, 26.04): debconf prompts appear; all deps satisfied
3. `cat /etc/default/cloud-router` shows non-secret config; `/etc/swanctl/conf.d/remote-site.conf`
and `road-warrior.conf` are present; `remote-site.conf` is mode 0600 and contains the
`secrets {}` block with the PSK; `/etc/wireguard/wg0.conf` exists only when `WG_ENABLED=true`
4. Source `simple-ca.sh`, then `make_ca "My Router CA"` → `/etc/cloud-router/pki/ca_cert.pem`
created; `make_cert --type server router.example.com` → cert/key produced;
`cloud-router-sync-pki` → files appear in `/etc/swanctl/`
5. `make_pfx <cert-path>` → `.pfx` produced for a road-warrior client
6. `systemctl status cloud-router-setup.service` is `active (exited)`; UFW `before.rules`
contains the IPsec/NAT/forwarding sections; when `WG_ENABLED=true`, WireGuard keys are
present at `/etc/wireguard/wg0.key` and `wg0.pub`
---
## Further Considerations
- **Ubuntu version compatibility** (must verify before finalising supported version list):
items to check on each of 22.04, 24.04, 26.04:
- `debhelper-compat (= 14)` — Ubuntu 22.04 ships debhelper 13.6 (max compat 13);
24.04 ships 13.11 (compat 14 supported); may need to drop to compat 13 for 22.04 support
- strongSwan package names (`strongswan-swanctl`, `charon-systemd`, plugin packages) —
verify availability and correct names on each release
- `wireguard-tools` availability and kernel module inclusion
- netplan API and YAML schema changes between releases
- `systemd-resolved` DNS stub behaviour differences
- **`dpkg-reconfigure` behaviour**: re-running `dpkg-reconfigure cloud-router` re-prompts
all debconf questions and regenerates config files, overwriting any manual edits. Files
"owned" by debconf (remote-site.conf, wg0.conf, netplan yaml, env) should be documented
as managed; swanctl cert files (written via `simple-ca.sh`) are not touched by postinst.
- **`simple-ca.sh` key algorithm support**: currently RSA-4096 only. ECDSA/Ed25519 support
is a planned future enhancement to `simple-ca.sh` itself and is independent of this package.
- **CRL / revocation**: `simple-ca.sh` has no revocation support. If road-warrior client
revocation is needed, `strongswan-pki` can be added as an optional dependency for
`pki --signcrl`. Treat as a future enhancement.
- **Build tooling**: `container` (Apple container CLI) is used to run `dpkg-buildpackage`
inside Ubuntu containers. The source tree is bind-mounted into the container; the built
`.deb` appears in the parent directory as usual. Inside the container: `apt-get install -y
build-essential debhelper`. No Ubuntu host or VM needed for builds.
- **Development environment**: source files are authored on macOS. `container` handles all
build and install testing — run the same loop against 22.04, 24.04, and 26.04 containers
once the compatibility matrix is resolved.
+28
View File
@@ -0,0 +1,28 @@
#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
+5
View File
@@ -0,0 +1,5 @@
cloud-router (1.0.0-1) unstable; urgency=medium
* Initial release.
-- Sławomir Koszewski <slawek@koszewscy.waw.pl> Tue, 26 May 2026 00:00:00 +0200
Vendored Executable
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
set -e
. /usr/share/debconf/confmodule
db_input high cloud-router/local_addrs || true
db_input high cloud-router/local_fqdn || true
db_input high cloud-router/local_id_mode || true
db_input high cloud-router/local_cidrs || true
db_input high cloud-router/remote_addrs || 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_get cloud-router/wg_enabled
if [ "$RET" = "true" ]; then
db_input high cloud-router/wg_address || true
db_input high cloud-router/wg_listen_port || true
db_go || true
fi
+29
View File
@@ -0,0 +1,29 @@
Source: cloud-router
Section: net
Priority: optional
Maintainer: Sławomir Koszewski <slawek@koszewscy.waw.pl>
Build-Depends: debhelper-compat (= 14)
Standards-Version: 4.6.2
Rules-Requires-Root: no
Package: cloud-router
Architecture: all
Depends: ${misc:Depends},
strongswan-swanctl,
charon-systemd,
libstrongswan-extra-plugins,
libcharon-extra-plugins,
wireguard-tools,
ufw,
debconf,
openssl,
python3-jinja2
Description: Linux cloud router with IPSec and optional WireGuard
Configures a Linux host as a cloud router providing site-to-site IKEv2
IPSec (strongSwan swanctl) and road-warrior P2S VPN (EAP-TLS). WireGuard
is optionally enabled. Includes a PKI helper library (simple-ca.sh) for
managing the road-warrior certificate authority.
.
Site-specific values are collected via debconf at install time and written
to /etc/default/cloud-router. A one-shot systemd service (cloud-router-setup)
applies UFW rules and WireGuard keys on first boot.
+28
View File
@@ -0,0 +1,28 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: cloud-router
Upstream-Contact: Sławomir Koszewski <slawek@koszewscy.waw.pl>
Files: *
Copyright: 2026 Sławomir Koszewski
License: MIT
License: MIT
MIT License
.
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.
Vendored
+10
View File
@@ -0,0 +1,10 @@
etc/cloud-router
etc/cloud-router/pki
etc/wireguard
etc/swanctl/conf.d
etc/swanctl/x509ca
etc/swanctl/x509
etc/swanctl/private
etc/systemd/resolved.conf.d
usr/lib/cloud-router
usr/share/cloud-router/templates
+4
View File
@@ -0,0 +1,4 @@
src/etc/sysctl.d/99-cloud-router.conf etc/sysctl.d/
src/usr/local/sbin/simple-ca usr/local/sbin/
src/usr/lib/cloud-router/configure usr/lib/cloud-router/
src/usr/share/cloud-router/templates/* usr/share/cloud-router/templates/
Vendored Executable
+53
View File
@@ -0,0 +1,53 @@
#!/bin/sh
set -e
. /usr/share/debconf/confmodule
case "$1" in
configure)
# ── Read debconf answers ──────────────────────────────────────────────
db_get cloud-router/local_addrs; CLOUD_ROUTER_LOCAL_ADDRS="$RET"
db_get cloud-router/local_fqdn; CLOUD_ROUTER_LOCAL_FQDN="$RET"
db_get cloud-router/local_id_mode; CLOUD_ROUTER_LOCAL_ID_MODE="$RET"
db_get cloud-router/local_cidrs; CLOUD_ROUTER_LOCAL_CIDRS="$RET"
db_get cloud-router/remote_addrs; CLOUD_ROUTER_REMOTE_ADDRS="$RET"
db_get cloud-router/remote_id; CLOUD_ROUTER_REMOTE_ID="$RET"
db_get cloud-router/psk; CLOUD_ROUTER_PSK="$RET"
db_get cloud-router/remote_cidrs; CLOUD_ROUTER_REMOTE_CIDRS="$RET"
db_get cloud-router/router_int_gateway_ip; CLOUD_ROUTER_ROUTER_INT_GATEWAY_IP="$RET"
db_get cloud-router/p2s_address_pool; CLOUD_ROUTER_P2S_ADDRESS_POOL="$RET"
db_get cloud-router/wg_enabled; CLOUD_ROUTER_WG_ENABLED="$RET"
db_get cloud-router/wg_address; CLOUD_ROUTER_WG_ADDRESS="$RET"
db_get cloud-router/wg_listen_port; CLOUD_ROUTER_WG_LISTEN_PORT="$RET"
# ── Render configuration files via Jinja2 templates ─────────────────
export CLOUD_ROUTER_LOCAL_ADDRS CLOUD_ROUTER_LOCAL_FQDN \
CLOUD_ROUTER_LOCAL_ID_MODE CLOUD_ROUTER_LOCAL_CIDRS \
CLOUD_ROUTER_REMOTE_ADDRS CLOUD_ROUTER_REMOTE_ID \
CLOUD_ROUTER_PSK CLOUD_ROUTER_REMOTE_CIDRS \
CLOUD_ROUTER_ROUTER_INT_GATEWAY_IP CLOUD_ROUTER_P2S_ADDRESS_POOL \
CLOUD_ROUTER_WG_ENABLED CLOUD_ROUTER_WG_ADDRESS \
CLOUD_ROUTER_WG_LISTEN_PORT
/usr/lib/cloud-router/configure
db_set cloud-router/psk ""
# ── Apply system settings ─────────────────────────────────────────────
sysctl --system
netplan apply
systemctl daemon-reload
systemctl restart systemd-resolved
# ── UFW: ensure SSH is allowed then enable ────────────────────────────
ufw allow 22/tcp
ufw --force enable
ufw reload
# ── strongSwan ────────────────────────────────────────────────────────
systemctl enable --now strongswan
;;
esac
#DEBHELPER#
db_stop
Vendored Executable
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -e
case "$1" in
remove|deconfigure)
systemctl disable --now strongswan || true
;;
esac
#DEBHELPER#
Vendored Executable
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/make -f
%:
dh $@
+80
View File
@@ -0,0 +1,80 @@
Template: cloud-router/local_addrs
Type: string
Description: Local WAN IP address(es)
Comma-separated list of local WAN IP addresses that strongSwan binds on
for the site-to-site and road-warrior tunnels (e.g. 10.1.2.3).
Template: cloud-router/local_fqdn
Type: string
Description: Local router FQDN
Fully-qualified domain name of this router (e.g. router.example.com).
Used as the road-warrior server identity and certificate CN.
Template: cloud-router/local_id_mode
Type: select
Choices: fqdn, public_ip, internal_ip
Default: fqdn
Description: IKE local identity mode
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
internal_ip — use the local WAN IP address
Template: cloud-router/local_cidrs
Type: string
Description: Local subnet CIDR(s)
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).
Template: cloud-router/remote_addrs
Type: string
Description: Remote site WAN IP address(es)
Comma-separated list of remote site WAN IP addresses for the
site-to-site IPSec tunnel.
Template: cloud-router/remote_id
Type: string
Description: Remote site IKE identity
IKE identity of the remote peer (FQDN, without leading @).
Template: cloud-router/psk
Type: password
Description: Pre-shared key (PSK)
Pre-shared key for the site-to-site IKEv2 tunnel. Must match the
value configured on the remote peer.
Template: cloud-router/remote_cidrs
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
Default: false
Description: Enable WireGuard VPN?
If true, WireGuard is configured on wg0 and its UFW rules are installed.
Template: cloud-router/wg_address
Type: string
Default: 10.0.1.1/24
Description: WireGuard interface address
IP address and prefix length for the wg0 interface (e.g. 10.0.1.1/24).
Only used when WireGuard is enabled.
Template: cloud-router/wg_listen_port
Type: string
Default: 51820
Description: WireGuard listen port
UDP port that WireGuard listens on. Only used when WireGuard is enabled.
Executable
+447
View File
@@ -0,0 +1,447 @@
# 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.
# These functions require OpenSSL to be installed on the system.
#
# SIMPLE_CA_DIR points to the root CA directory. It can be set in the environment
# before sourcing this file, or overridden per-call with --ca-dir. Once set by any
# call, subsequent calls in the same session inherit it.
#
# Directory layout:
# $SIMPLE_CA_DIR/ca_cert.pem — root CA certificate
# $SIMPLE_CA_DIR/ca_key.pem — root CA private key
# $SIMPLE_CA_DIR/{name}_cert.pem — certificates issued by the root CA
# $SIMPLE_CA_DIR/{issuing_ca}/ca_cert.pem — issuing CA certificate
# $SIMPLE_CA_DIR/{issuing_ca}/ca_key.pem — issuing CA private key
# $SIMPLE_CA_DIR/{issuing_ca}/{name}_cert.pem — certificates issued by that issuing CA
#
# Certificates are always written to the directory of the CA that signs them.
# Any subdirectory containing ca_cert.pem is treated as an issuing CA.
SIMPLE_CA_DIR="${SIMPLE_CA_DIR:-}"
_rebuild_ca_bundle() {
local BUNDLE="$SIMPLE_CA_DIR/ca_bundle.pem"
cat "$SIMPLE_CA_DIR/ca_cert.pem" > "$BUNDLE"
for issuing_ca_dir in $SIMPLE_CA_DIR/*; do
if [[ -d "$issuing_ca_dir" && -f "$issuing_ca_dir/ca_cert.pem" ]]; then
cat "$issuing_ca_dir/ca_cert.pem" >> "$BUNDLE"
fi
done
}
_require_ca_dir() {
SIMPLE_CA_DIR="${SIMPLE_CA_DIR:-$(pwd)}"
if [[ ! -d "$SIMPLE_CA_DIR" ]]; then
echo "ERROR: CA directory '$SIMPLE_CA_DIR' does not exist." >&2
return 1
fi
}
_is_ip() { [[ "$1" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; }
_is_dns() { [[ "$1" =~ ^[a-z0-9-]+(\.[a-z0-9-]+)*$ ]]; }
_is_email() { [[ "$1" =~ ^[^@]+@[^@]+\.[^@]+$ ]]; }
make_ca() {
local CA_DAYS=3650
local ISSUING_CA=""
local AIA_BASE_URL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--days)
if [[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]]; then
echo "ERROR: --days requires a positive integer." >&2
return 1
fi
CA_DAYS="$2"; shift 2 ;;
--issuing-ca)
if [[ -z "$2" ]]; then
echo "ERROR: --issuing-ca requires a value." >&2
return 1
fi
if [[ "$2" == "ca" ]]; then
echo "ERROR: --issuing-ca cannot be 'ca'." >&2
return 1
fi
ISSUING_CA="$2"; shift 2 ;;
--aia-base-url)
if [[ -z "$2" ]]; then
echo "ERROR: --aia-base-url requires a value." >&2
return 1
fi
AIA_BASE_URL="$2"; shift 2 ;;
--ca-dir)
if [[ -z "$2" ]]; then
echo "ERROR: --ca-dir requires a value." >&2
return 1
fi
SIMPLE_CA_DIR="$2"; shift 2 ;;
*) break ;;
esac
done
local CA_NAME="$1"
_require_ca_dir || return 1
if [[ -z "$CA_NAME" ]]; then
echo "ERROR: CA name is required." >&2
return 1
fi
if [[ -z "$AIA_BASE_URL" && -f "$SIMPLE_CA_DIR/aia_base_url.txt" ]]; then
AIA_BASE_URL="$(cat "$SIMPLE_CA_DIR/aia_base_url.txt")"
fi
local ROOT_CA_CERT="$SIMPLE_CA_DIR/ca_cert.pem"
local ROOT_CA_KEY="$SIMPLE_CA_DIR/ca_key.pem"
# Create root CA
if [[ -z "$ISSUING_CA" ]]; then
if [[ -f "$ROOT_CA_CERT" && -f "$ROOT_CA_KEY" ]]; then
echo "Root CA already exists in $SIMPLE_CA_DIR, skipping."
return 0
fi
echo "Generating root CA certificate '$CA_NAME' and key..."
if ! openssl req \
-x509 \
-newkey rsa:4096 \
-keyout "$ROOT_CA_KEY" \
-out "$ROOT_CA_CERT" \
-days "$CA_DAYS" \
-noenc \
-subj "/CN=${CA_NAME}" \
-text \
-addext "basicConstraints=critical,CA:TRUE,pathlen:1" \
-addext "keyUsage=critical,keyCertSign,cRLSign"; then
echo "ERROR: Failed to generate root CA certificate and key." >&2
return 1
fi
_rebuild_ca_bundle
if [[ -n "$AIA_BASE_URL" ]]; then
echo "$AIA_BASE_URL" > "$SIMPLE_CA_DIR/aia_base_url.txt"
fi
return 0
fi
# Create issuing CA
if [[ ! -f "$ROOT_CA_CERT" || ! -f "$ROOT_CA_KEY" ]]; then
echo "ERROR: Cannot create issuing CA '$CA_NAME' without an existing root CA. Create the root CA first." >&2
return 1
fi
local ISSUING_DIR="$SIMPLE_CA_DIR/$ISSUING_CA"
local ISSUING_CERT="$ISSUING_DIR/ca_cert.pem"
local ISSUING_KEY="$ISSUING_DIR/ca_key.pem"
if [[ -f "$ISSUING_CERT" && -f "$ISSUING_KEY" ]]; then
echo "Issuing CA '$ISSUING_CA' already exists in $ISSUING_DIR, skipping."
return 0
fi
mkdir -p "$ISSUING_DIR"
echo "Generating issuing CA certificate '$CA_NAME' and key..."
if ! openssl req \
-newkey rsa:4096 \
-keyout "$ISSUING_KEY" \
-noenc \
-subj "/CN=${CA_NAME}" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
${AIA_BASE_URL:+-addext "authorityInfoAccess=caIssuers;URI:${AIA_BASE_URL}/ca_cert.crt"} \
| openssl x509 \
-req \
-CA "$ROOT_CA_CERT" \
-CAkey "$ROOT_CA_KEY" \
-copy_extensions copyall \
-days "$CA_DAYS" \
-text \
-out "$ISSUING_CERT"; then
echo "ERROR: Failed to generate issuing CA certificate and key." >&2
return 1
fi
_rebuild_ca_bundle
return 0
}
make_cert() {
local ISSUING_CA=""
local CERT_DAYS=365
local CERT_DIR=""
local CERT_TYPE="server"
while [[ $# -gt 0 ]]; do
case "$1" in
--ca-dir)
if [[ -z "$2" ]]; then
echo "ERROR: --ca-dir requires a value." >&2
return 1
fi
SIMPLE_CA_DIR="$2"; shift 2 ;;
--cert-dir)
if [[ -z "$2" ]]; then
echo "ERROR: --cert-dir requires a value." >&2
return 1
fi
CERT_DIR="$2"; shift 2 ;;
--issuing-ca)
if [[ -z "$2" ]]; then
echo "ERROR: --issuing-ca requires a value." >&2
return 1
fi
if [[ "$2" == "ca" ]]; then
echo "ERROR: --issuing-ca cannot be 'ca'." >&2
return 1
fi
ISSUING_CA="$2"; shift 2 ;;
--days)
if [[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]]; then
echo "ERROR: --days requires a positive integer." >&2
return 1
fi
CERT_DAYS="$2"; shift 2 ;;
--type)
if [[ -z "$2" ]]; then
echo "ERROR: --type requires a value." >&2
return 1
fi
if [[ "$2" != "server" && "$2" != "user" ]]; then
echo "ERROR: --type must be 'server' or 'user'." >&2
return 1
fi
CERT_TYPE="$2"; shift 2 ;;
*) break ;;
esac
done
local CERT_SUBJECT_NAME="$1"
if [[ $# -gt 0 ]]; then
shift
fi
_require_ca_dir || return 1
if [[ -z "$CERT_SUBJECT_NAME" ]]; then
echo "ERROR: Subject name is required." >&2
return 1
fi
if [[ "$CERT_TYPE" == "server" ]] && ! _is_dns "$CERT_SUBJECT_NAME"; then
echo "ERROR: Invalid subject name '$CERT_SUBJECT_NAME'. Must be a valid DNS name." >&2
return 1
fi
local SIGNING_DIR="$SIMPLE_CA_DIR${ISSUING_CA:+/$ISSUING_CA}"
local SIGNING_CERT="$SIGNING_DIR/ca_cert.pem"
local SIGNING_KEY="$SIGNING_DIR/ca_key.pem"
CERT_DIR="${CERT_DIR:-$SIGNING_DIR}"
if [[ ! -f "$SIGNING_CERT" || ! -f "$SIGNING_KEY" ]]; then
echo "ERROR: Signing CA certificate and key not found in $SIGNING_DIR." >&2
return 1
fi
local AIA_URL=""
if [[ -f "$SIMPLE_CA_DIR/aia_base_url.txt" ]]; then
local BASE_URL
BASE_URL="$(cat "$SIMPLE_CA_DIR/aia_base_url.txt")"
AIA_URL="${BASE_URL}${ISSUING_CA:+/$ISSUING_CA}/ca_cert.crt"
fi
local CERT_NAME="${CERT_SUBJECT_NAME%%.*}"
local SANS=()
if [[ "$CERT_TYPE" == "server" ]]; then
SANS=("DNS:${CERT_SUBJECT_NAME}")
fi
while [[ $# -gt 0 ]]; do
if _is_ip "$1"; then
SANS+=("IP:$1")
elif _is_email "$1"; then
SANS+=("email:$1")
elif _is_dns "$1"; then
SANS+=("DNS:$1")
else
echo "ERROR: Invalid SAN entry '$1'." >&2
return 1
fi
shift
done
if [[ "$CERT_TYPE" == "server" ]]; then
echo "Generating server certificate for '$CERT_SUBJECT_NAME' with SANs:"
else
echo "Generating user certificate for '$CERT_SUBJECT_NAME':"
fi
for san in "${SANS[@]}"; do echo " - $san"; done
if [[ -f "$CERT_DIR/${CERT_NAME}_cert.pem" && -f "$CERT_DIR/${CERT_NAME}_key.pem" ]]; then
echo "Certificate already exists in $CERT_DIR, skipping."
return 0
fi
local REQ_ARGS=(
-newkey rsa:4096
-keyout "$CERT_DIR/${CERT_NAME}_key.pem"
-noenc
-subj "/CN=${CERT_SUBJECT_NAME}"
-addext "basicConstraints=critical,CA:FALSE"
)
local X509_ARGS=(
-req
-CA "$SIGNING_CERT"
-CAkey "$SIGNING_KEY"
-copy_extensions copyall
-days "$CERT_DAYS"
-text
-out "$CERT_DIR/${CERT_NAME}_cert.pem"
)
if [[ "$CERT_TYPE" == "server" ]]; then
REQ_ARGS+=(
-addext "keyUsage=critical,digitalSignature,keyEncipherment"
-addext "extendedKeyUsage=serverAuth,clientAuth"
-addext "subjectAltName=$(IFS=,; echo "${SANS[*]}")"
)
else
REQ_ARGS+=(
-addext "keyUsage=critical,digitalSignature,nonRepudiation"
-addext "extendedKeyUsage=clientAuth,emailProtection,codeSigning"
)
if [[ ${#SANS[@]} -gt 0 ]]; then
REQ_ARGS+=(-addext "subjectAltName=$(IFS=,; echo "${SANS[*]}")")
fi
fi
if [[ -n "$AIA_URL" ]]; then
REQ_ARGS+=(-addext "authorityInfoAccess=caIssuers;URI:${AIA_URL}")
fi
echo "Generating certificate and key..."
if ! openssl req "${REQ_ARGS[@]}" | openssl x509 "${X509_ARGS[@]}"; then
echo "ERROR: Failed to generate certificate and key." >&2
return 1
fi
}
make_pfx() {
local ISSUING_CA=""
local PFX_PASSWORD=""
local APPLE_OPENSSL=0
while [[ $# -gt 0 ]]; do
case "$1" in
--ca-dir)
if [[ -z "$2" ]]; then
echo "ERROR: --ca-dir requires a value." >&2
return 1
fi
SIMPLE_CA_DIR="$2"; shift 2 ;;
--issuing-ca)
if [[ -z "$2" ]]; then
echo "ERROR: --issuing-ca requires a value." >&2
return 1
fi
if [[ "$2" == "ca" ]]; then
echo "ERROR: --issuing-ca cannot be 'ca'." >&2
return 1
fi
ISSUING_CA="$2"; shift 2 ;;
--password)
if [[ -z "$2" ]]; then
echo "ERROR: --password requires a value." >&2
return 1
fi
PFX_PASSWORD="$2"; shift 2 ;;
--apple-openssl)
APPLE_OPENSSL=1; shift ;;
*) break ;;
esac
done
local CERT_PATH="$1"
_require_ca_dir || return 1
if [[ -z "$CERT_PATH" ]]; then
echo "ERROR: Certificate path is required." >&2
return 1
fi
local CERT_DIR CERT_NAME KEY_PATH
CERT_DIR="$(dirname "$CERT_PATH")"
CERT_NAME="$(basename "$CERT_PATH" _cert.pem)"
KEY_PATH="$CERT_DIR/${CERT_NAME}_key.pem"
if [[ ! -d "$CERT_DIR" ]]; then
echo "ERROR: Certificate directory '$CERT_DIR' does not exist." >&2
return 1
fi
if [[ ! -f "$CERT_PATH" || ! -f "$KEY_PATH" ]]; then
echo "ERROR: Server certificate or key not found." >&2
return 1
fi
if [[ ! -f "$SIMPLE_CA_DIR/ca_cert.pem" ]]; then
echo "ERROR: Root CA certificate not found in $SIMPLE_CA_DIR." >&2
return 1
fi
if [[ -n "$ISSUING_CA" && ! -f "$SIMPLE_CA_DIR/$ISSUING_CA/ca_cert.pem" ]]; then
echo "ERROR: Issuing CA certificate not found in $SIMPLE_CA_DIR/$ISSUING_CA." >&2
return 1
fi
if [[ -f "$CERT_DIR/${CERT_NAME}.pfx" ]]; then
echo "PKCS#12 (PFX) file already exists, aborting generation." >&2
return 1
fi
PFX_PASSWORD="${PFX_PASSWORD:-changeit}"
local OPENSSL_BIN="openssl"
if [[ "$APPLE_OPENSSL" -eq 1 ]]; then
OPENSSL_BIN="/usr/bin/openssl"
fi
echo -n "Generating PKCS#12 (PFX) file..."
local CHAIN_FILE
CHAIN_FILE=$(mktemp)
trap "rm -f '$CHAIN_FILE'" EXIT QUIT KILL INT HUP
cat "$SIMPLE_CA_DIR/ca_cert.pem" > "$CHAIN_FILE"
if [[ -n "$ISSUING_CA" ]]; then
cat "$SIMPLE_CA_DIR/$ISSUING_CA/ca_cert.pem" >> "$CHAIN_FILE"
fi
if ! "$OPENSSL_BIN" pkcs12 \
-export \
-out "$CERT_DIR/${CERT_NAME}.pfx" \
-inkey "$KEY_PATH" \
-in "$CERT_PATH" \
-certfile "$CHAIN_FILE" \
-password "pass:${PFX_PASSWORD}"; then
echo "ERROR: Failed to generate PKCS#12 (PFX) file." >&2
return 1
fi
echo "done."
}
+6
View File
@@ -0,0 +1,6 @@
# Enable forwarding
net.ipv4.ip_forward = 1
# Disable strict rp_filter that breaks asymmetric forwarding on multi-NIC routers
net.ipv4.conf.all.rp_filter = 0
net.ipv4.conf.default.rp_filter = 0
+257
View File
@@ -0,0 +1,257 @@
#!/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()
+208
View File
@@ -0,0 +1,208 @@
#!/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()
@@ -0,0 +1,7 @@
network:
version: 2
ethernets:
eth1:
routes:
- to: {{ local_subnet }}
via: {{ router_int_gateway_ip }}
@@ -0,0 +1,14 @@
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 }}"
@@ -0,0 +1,2 @@
[Resolve]
DNSStubListenerExtra={{ local_addrs }}
@@ -0,0 +1,46 @@
connections {
remote-site {
version = 2
proposals = aes256-sha256-modp2048,aes256-sha1-modp1024
reauth_time = 28800
unique = replace
local_addrs = {{ local_addrs }}
remote_addrs = {{ remote_addrs }}
local {
id = {{ local_id }}
auth = psk
}
remote {
id = @{{ remote_id }}
auth = psk
}
children {
site2site {
mode = tunnel
local_ts = {{ local_cidrs }}
remote_ts = {{ remote_cidrs }}
esp_proposals = aes256-sha256,aes256-sha1
life_time = 3600
dpd_action = restart
start_action = start
close_action = none
}
}
rekey_time = 10800
dpd_delay = 10
dpd_timeout = 120
}
}
secrets {
ike-psk {
id-1 = {{ local_id }}
id-2 = @{{ remote_id }}
secret = "{{ psk }}"
}
}
@@ -0,0 +1,43 @@
connections {
road-warrior {
version = 2
proposals = aes256-sha256-modp2048,aes256-sha1-modp1024
reauth_time = 28800
dpd_delay = 10
dpd_timeout = 120
local {
auth = pubkey
certs = server.pem
id = @{{ local_fqdn }}
}
remote {
auth = eap-tls
cacerts = ca.pem
eap_id = %any
}
children {
road-warrior {
mode = tunnel
local_ts = {{ local_cidrs }}
remote_ts = dynamic
esp_proposals = aes256-sha256,aes256-sha1
life_time = 3600
dpd_action = clear
start_action = none
close_action = none
}
}
pools = rw-pool
}
}
pools {
rw-pool {
addrs = {{ p2s_address_pool }}
dns = {{ local_addrs }}
}
}
@@ -0,0 +1,4 @@
[Interface]
PostUp = wg set %i private-key /etc/wireguard/wg0.key
ListenPort = {{ wg_listen_port }}
Address = {{ wg_address }}