diff --git a/Plan.md b/Plan.md new file mode 100644 index 0000000..bc79893 --- /dev/null +++ b/Plan.md @@ -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 ` → `.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. diff --git a/cloud-router-cloud-init.yaml.tpl b/cloud-router-cloud-init.yaml.tpl new file mode 100644 index 0000000..7065366 --- /dev/null +++ b/cloud-router-cloud-init.yaml.tpl @@ -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 diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..5f338ec --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +cloud-router (1.0.0-1) unstable; urgency=medium + + * Initial release. + + -- Sławomir Koszewski Tue, 26 May 2026 00:00:00 +0200 diff --git a/debian/config b/debian/config new file mode 100755 index 0000000..df9923d --- /dev/null +++ b/debian/config @@ -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 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..39ed418 --- /dev/null +++ b/debian/control @@ -0,0 +1,29 @@ +Source: cloud-router +Section: net +Priority: optional +Maintainer: Sławomir Koszewski +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. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..42a08ec --- /dev/null +++ b/debian/copyright @@ -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 + +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. diff --git a/debian/dirs b/debian/dirs new file mode 100644 index 0000000..08e592e --- /dev/null +++ b/debian/dirs @@ -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 diff --git a/debian/install b/debian/install new file mode 100644 index 0000000..8fbfb94 --- /dev/null +++ b/debian/install @@ -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/ diff --git a/debian/postinst b/debian/postinst new file mode 100755 index 0000000..038c481 --- /dev/null +++ b/debian/postinst @@ -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 diff --git a/debian/prerm b/debian/prerm new file mode 100755 index 0000000..6d11b05 --- /dev/null +++ b/debian/prerm @@ -0,0 +1,10 @@ +#!/bin/sh +set -e + +case "$1" in + remove|deconfigure) + systemctl disable --now strongswan || true + ;; +esac + +#DEBHELPER# diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..cbe925d --- /dev/null +++ b/debian/rules @@ -0,0 +1,3 @@ +#!/usr/bin/make -f +%: + dh $@ diff --git a/debian/templates b/debian/templates new file mode 100644 index 0000000..7728932 --- /dev/null +++ b/debian/templates @@ -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. diff --git a/simple-ca.sh b/simple-ca.sh new file mode 100755 index 0000000..99abe3a --- /dev/null +++ b/simple-ca.sh @@ -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." +} diff --git a/src/etc/sysctl.d/99-cloud-router.conf b/src/etc/sysctl.d/99-cloud-router.conf new file mode 100644 index 0000000..1ed5449 --- /dev/null +++ b/src/etc/sysctl.d/99-cloud-router.conf @@ -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 diff --git a/src/usr/lib/cloud-router/configure b/src/usr/lib/cloud-router/configure new file mode 100755 index 0000000..3567dd3 --- /dev/null +++ b/src/usr/lib/cloud-router/configure @@ -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() diff --git a/src/usr/local/sbin/simple-ca b/src/usr/local/sbin/simple-ca new file mode 100755 index 0000000..a406cbf --- /dev/null +++ b/src/usr/local/sbin/simple-ca @@ -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 ', 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() diff --git a/src/usr/share/cloud-router/templates/90-cloud-router.yaml.j2 b/src/usr/share/cloud-router/templates/90-cloud-router.yaml.j2 new file mode 100644 index 0000000..99db2b4 --- /dev/null +++ b/src/usr/share/cloud-router/templates/90-cloud-router.yaml.j2 @@ -0,0 +1,7 @@ +network: + version: 2 + ethernets: + eth1: + routes: + - to: {{ local_subnet }} + via: {{ router_int_gateway_ip }} diff --git a/src/usr/share/cloud-router/templates/cloud-router.default.j2 b/src/usr/share/cloud-router/templates/cloud-router.default.j2 new file mode 100644 index 0000000..856508f --- /dev/null +++ b/src/usr/share/cloud-router/templates/cloud-router.default.j2 @@ -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 }}" diff --git a/src/usr/share/cloud-router/templates/p2s-forwarder.conf.j2 b/src/usr/share/cloud-router/templates/p2s-forwarder.conf.j2 new file mode 100644 index 0000000..e95ba3a --- /dev/null +++ b/src/usr/share/cloud-router/templates/p2s-forwarder.conf.j2 @@ -0,0 +1,2 @@ +[Resolve] +DNSStubListenerExtra={{ local_addrs }} diff --git a/src/usr/share/cloud-router/templates/remote-site.conf.j2 b/src/usr/share/cloud-router/templates/remote-site.conf.j2 new file mode 100644 index 0000000..60235f3 --- /dev/null +++ b/src/usr/share/cloud-router/templates/remote-site.conf.j2 @@ -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 }}" + } +} diff --git a/src/usr/share/cloud-router/templates/road-warrior.conf.j2 b/src/usr/share/cloud-router/templates/road-warrior.conf.j2 new file mode 100644 index 0000000..51e22fc --- /dev/null +++ b/src/usr/share/cloud-router/templates/road-warrior.conf.j2 @@ -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 }} + } +} diff --git a/src/usr/share/cloud-router/templates/wg0.conf.j2 b/src/usr/share/cloud-router/templates/wg0.conf.j2 new file mode 100644 index 0000000..96bdf65 --- /dev/null +++ b/src/usr/share/cloud-router/templates/wg0.conf.j2 @@ -0,0 +1,4 @@ +[Interface] +PostUp = wg set %i private-key /etc/wireguard/wg0.key +ListenPort = {{ wg_listen_port }} +Address = {{ wg_address }}