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:
@@ -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.
|
||||||
@@ -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
|
||||||
Vendored
+5
@@ -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
|
||||||
+23
@@ -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
|
||||||
Vendored
+29
@@ -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.
|
||||||
Vendored
+28
@@ -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
@@ -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
|
||||||
Vendored
+4
@@ -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/
|
||||||
+53
@@ -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
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
remove|deconfigure)
|
||||||
|
systemctl disable --now strongswan || true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
#DEBHELPER#
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/make -f
|
||||||
|
%:
|
||||||
|
dh $@
|
||||||
Vendored
+80
@@ -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
@@ -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."
|
||||||
|
}
|
||||||
@@ -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
@@ -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()
|
||||||
Executable
+208
@@ -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 }}
|
||||||
Reference in New Issue
Block a user