Moved Debian package sources to a subdirectory of the repository.

This commit is contained in:
2026-05-28 09:18:43 +02:00
parent ac6482a4a1
commit 1f6d168fb3
22 changed files with 3 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
ARG UBUNTU_VERSION=24.04
FROM ubuntu:${UBUNTU_VERSION}
ARG HOST_UID
ARG HOST_GID
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
dpkg-dev \
debhelper \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY . src/
RUN cd src && dpkg-buildpackage -us -uc -b
RUN mkdir /out \
&& find /build -maxdepth 1 \( -name '*.deb' -o -name '*.buildinfo' -o -name '*.changes' \) \
-exec install -o "$HOST_UID" -g "$HOST_GID" -m 0644 {} /out/ \;
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -e
mkdir -p out
container build --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -t cloud-router-builder "$@" .
container run --rm -v "$(pwd)/out:/mnt" cloud-router-builder sh -c 'cp -p /out/* /mnt/'
echo "Build artifacts written to out/"
+5
View File
@@ -0,0 +1,5 @@
cloud-router (1.0.0-1) unstable; urgency=medium
* Initial release.
-- Sławomir Koszewski <slawek@koszewscy.waw.pl> Tue, 26 May 2026 00:00:00 +0200
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
set -e
. /usr/share/debconf/confmodule
db_input high cloud-router/local_addrs || true
db_input high cloud-router/local_fqdn || true
db_input high cloud-router/local_id_mode || true
db_input high cloud-router/local_cidrs || true
db_input high cloud-router/remote_addrs || true
db_input high cloud-router/remote_id || true
db_input high cloud-router/psk || true
db_input high cloud-router/remote_cidrs || true
db_input high cloud-router/router_int_gateway_ip || true
db_input high cloud-router/p2s_address_pool || true
db_input high cloud-router/wg_enabled || true
db_go || true
db_get cloud-router/wg_enabled
if [ "$RET" = "true" ]; then
db_input high cloud-router/wg_address || true
db_input high cloud-router/wg_listen_port || true
db_go || true
fi
+29
View File
@@ -0,0 +1,29 @@
Source: cloud-router
Section: net
Priority: optional
Maintainer: Sławomir Koszewski <slawek@koszewscy.waw.pl>
Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.6.2
Rules-Requires-Root: no
Package: cloud-router
Architecture: all
Depends: ${misc:Depends},
strongswan-swanctl,
charon-systemd,
libstrongswan-extra-plugins,
libcharon-extra-plugins,
wireguard-tools,
ufw,
debconf,
openssl,
python3-jinja2
Description: Linux cloud router with IPSec and optional WireGuard
Configures a Linux host as a cloud router providing site-to-site IKEv2
IPSec (strongSwan swanctl) and road-warrior P2S VPN (EAP-TLS). WireGuard
is optionally enabled. Includes a PKI helper library (simple-ca.sh) for
managing the road-warrior certificate authority.
.
Site-specific values are collected via debconf at install time and written
to /etc/default/cloud-router. A one-shot systemd service (cloud-router-setup)
applies UFW rules and WireGuard keys on first boot.
+28
View File
@@ -0,0 +1,28 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: cloud-router
Upstream-Contact: Sławomir Koszewski <slawek@koszewscy.waw.pl>
Files: *
Copyright: 2026 Sławomir Koszewski
License: MIT
License: MIT
MIT License
.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+10
View File
@@ -0,0 +1,10 @@
etc/cloud-router
etc/cloud-router/pki
etc/wireguard
etc/swanctl/conf.d
etc/swanctl/x509ca
etc/swanctl/x509
etc/swanctl/private
etc/systemd/resolved.conf.d
usr/lib/cloud-router
usr/share/cloud-router/templates
+4
View File
@@ -0,0 +1,4 @@
src/etc/sysctl.d/99-cloud-router.conf etc/sysctl.d/
src/opt/cloud-router/bin/simple-ca opt/cloud-router/bin/
src/usr/lib/cloud-router/configure usr/lib/cloud-router/
src/usr/share/cloud-router/templates/* usr/share/cloud-router/templates/
+53
View File
@@ -0,0 +1,53 @@
#!/bin/sh
set -e
. /usr/share/debconf/confmodule
case "$1" in
configure)
# ── Read debconf answers ──────────────────────────────────────────────
db_get cloud-router/local_addrs; CLOUD_ROUTER_LOCAL_ADDRS="$RET"
db_get cloud-router/local_fqdn; CLOUD_ROUTER_LOCAL_FQDN="$RET"
db_get cloud-router/local_id_mode; CLOUD_ROUTER_LOCAL_ID_MODE="$RET"
db_get cloud-router/local_cidrs; CLOUD_ROUTER_LOCAL_CIDRS="$RET"
db_get cloud-router/remote_addrs; CLOUD_ROUTER_REMOTE_ADDRS="$RET"
db_get cloud-router/remote_id; CLOUD_ROUTER_REMOTE_ID="$RET"
db_get cloud-router/psk; CLOUD_ROUTER_PSK="$RET"
db_get cloud-router/remote_cidrs; CLOUD_ROUTER_REMOTE_CIDRS="$RET"
db_get cloud-router/router_int_gateway_ip; CLOUD_ROUTER_ROUTER_INT_GATEWAY_IP="$RET"
db_get cloud-router/p2s_address_pool; CLOUD_ROUTER_P2S_ADDRESS_POOL="$RET"
db_get cloud-router/wg_enabled; CLOUD_ROUTER_WG_ENABLED="$RET"
db_get cloud-router/wg_address; CLOUD_ROUTER_WG_ADDRESS="$RET"
db_get cloud-router/wg_listen_port; CLOUD_ROUTER_WG_LISTEN_PORT="$RET"
# ── Render configuration files via Jinja2 templates ─────────────────
export CLOUD_ROUTER_LOCAL_ADDRS CLOUD_ROUTER_LOCAL_FQDN \
CLOUD_ROUTER_LOCAL_ID_MODE CLOUD_ROUTER_LOCAL_CIDRS \
CLOUD_ROUTER_REMOTE_ADDRS CLOUD_ROUTER_REMOTE_ID \
CLOUD_ROUTER_PSK CLOUD_ROUTER_REMOTE_CIDRS \
CLOUD_ROUTER_ROUTER_INT_GATEWAY_IP CLOUD_ROUTER_P2S_ADDRESS_POOL \
CLOUD_ROUTER_WG_ENABLED CLOUD_ROUTER_WG_ADDRESS \
CLOUD_ROUTER_WG_LISTEN_PORT
/usr/lib/cloud-router/configure
db_set cloud-router/psk ""
# ── Apply system settings ─────────────────────────────────────────────
sysctl --system
netplan apply
systemctl daemon-reload
systemctl restart systemd-resolved
# ── UFW: ensure SSH is allowed then enable ────────────────────────────
ufw allow 22/tcp
ufw --force enable
ufw reload
# ── strongSwan ────────────────────────────────────────────────────────
systemctl enable --now strongswan
;;
esac
#DEBHELPER#
db_stop
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -e
case "$1" in
remove|deconfigure)
systemctl disable --now strongswan || true
;;
esac
#DEBHELPER#
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/make -f
%:
dh $@
+80
View File
@@ -0,0 +1,80 @@
Template: cloud-router/local_addrs
Type: string
Description: Local WAN IP address(es)
Comma-separated list of local WAN IP addresses that strongSwan binds on
for the site-to-site and road-warrior tunnels (e.g. 10.1.2.3).
Template: cloud-router/local_fqdn
Type: string
Description: Local router FQDN
Fully-qualified domain name of this router (e.g. router.example.com).
Used as the road-warrior server identity and certificate CN.
Template: cloud-router/local_id_mode
Type: select
Choices: fqdn, public_ip, internal_ip
Default: fqdn
Description: IKE local identity mode
How to derive the IKE identity advertised to the remote site:
fqdn — use the FQDN (default; requires matching on remote side)
public_ip — resolve the public IP from DNS at first boot
internal_ip — use the local WAN IP address
Template: cloud-router/local_cidrs
Type: string
Description: Local subnet CIDR(s)
Comma-separated list of local subnet CIDRs to advertise into the
site-to-site tunnel (e.g. 10.0.0.0/24 or 10.0.0.0/24,10.0.1.0/24).
Template: cloud-router/remote_addrs
Type: string
Description: Remote site WAN IP address(es)
Comma-separated list of remote site WAN IP addresses for the
site-to-site IPSec tunnel.
Template: cloud-router/remote_id
Type: string
Description: Remote site IKE identity
IKE identity of the remote peer (FQDN, without leading @).
Template: cloud-router/psk
Type: password
Description: Pre-shared key (PSK)
Pre-shared key for the site-to-site IKEv2 tunnel. Must match the
value configured on the remote peer.
Template: cloud-router/remote_cidrs
Type: string
Description: Remote subnet CIDR(s)
Comma-separated list of remote subnet CIDRs for the site-to-site
tunnel (e.g. 192.168.0.0/24).
Template: cloud-router/router_int_gateway_ip
Type: string
Description: Internal network gateway IP
IP address of the next-hop gateway on the internal NIC (eth1).
Used in the netplan route for the local subnet.
Template: cloud-router/p2s_address_pool
Type: string
Description: Road-warrior address pool
CIDR block assigned to road-warrior VPN clients (e.g. 172.16.0.0/24).
Template: cloud-router/wg_enabled
Type: boolean
Default: false
Description: Enable WireGuard VPN?
If true, WireGuard is configured on wg0 and its UFW rules are installed.
Template: cloud-router/wg_address
Type: string
Default: 10.0.1.1/24
Description: WireGuard interface address
IP address and prefix length for the wg0 interface (e.g. 10.0.1.1/24).
Only used when WireGuard is enabled.
Template: cloud-router/wg_listen_port
Type: string
Default: 51820
Description: WireGuard listen port
UDP port that WireGuard listens on. Only used when WireGuard is enabled.
+3
View File
@@ -0,0 +1,3 @@
curl -v --user "slawek:$(cat ~/.gitea-packages-token)" \
--upload-file out/cloud-router_1.0.0-1_all.deb \
"https://gitea.koszewscy.waw.pl/api/packages/slawek/debian/pool/noble/main/upload"
@@ -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
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
# MIT License
#
# Copyright (c) 2026 Sławomir Koszewski
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""simple-ca — minimal CA for cloud-router IKEv2 PKI.
Directory layout:
/etc/swanctl/x509ca/ca.pem CA certificate (strongSwan trust anchor)
/etc/swanctl/private/ca.key CA private key
/etc/swanctl/x509/server.pem server certificate
/etc/swanctl/private/server.key server private key
/etc/cloud-router/pki/{name}_cert.pem user/road-warrior certificate
/etc/cloud-router/pki/{name}_key.pem user/road-warrior private key
/etc/cloud-router/pki/{name}.pfx PKCS#12 bundle for client distribution
"""
import argparse
import subprocess
import sys
from pathlib import Path
CA_CERT = Path('/etc/swanctl/x509ca/ca.pem')
CA_KEY = Path('/etc/swanctl/private/ca.key')
SERVER_CERT = Path('/etc/swanctl/x509/server.pem')
SERVER_KEY = Path('/etc/swanctl/private/server.key')
PKI_DIR = Path('/etc/cloud-router/pki')
def _run(*args, stdin=None):
result = subprocess.run(list(args), input=stdin, capture_output=True)
if result.returncode != 0:
sys.stderr.buffer.write(result.stderr)
sys.exit(1)
return result.stdout
def _san_prefix(s):
if '@' in s:
return f'email:{s}'
parts = s.split('.')
if len(parts) == 4 and all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
return f'IP:{s}'
return f'DNS:{s}'
def cmd_make_ca(args):
if CA_CERT.exists() and CA_KEY.exists():
print('Root CA already exists, skipping.')
return
print('Generating root CA certificate and key...')
_run(
'openssl', 'req',
'-x509',
'-newkey', 'rsa:4096',
'-keyout', str(CA_KEY),
'-out', str(CA_CERT),
'-days', str(args.days),
'-noenc',
'-subj', f'/CN={args.name}',
'-text',
'-addext', 'basicConstraints=critical,CA:TRUE,pathlen:1',
'-addext', 'keyUsage=critical,keyCertSign,cRLSign',
)
CA_KEY.chmod(0o600)
print(f'Root CA created.')
print(f' Certificate : {CA_CERT}')
print(f' Private key : {CA_KEY}')
def cmd_make_cert(args):
name = args.subject.split('.')[0]
if args.type == 'server':
cert_path = SERVER_CERT
key_path = SERVER_KEY
else:
cert_path = PKI_DIR / f'{name}_cert.pem'
key_path = PKI_DIR / f'{name}_key.pem'
if cert_path.exists() and key_path.exists():
print(f'Certificate already exists at {cert_path}, skipping.')
return
if not CA_CERT.exists() or not CA_KEY.exists():
print('ERROR: Root CA not found. Run: simple-ca make-ca <name>', file=sys.stderr)
sys.exit(1)
req_args = [
'openssl', 'req',
'-newkey', 'rsa:4096',
'-keyout', str(key_path),
'-noenc',
'-subj', f'/CN={args.subject}',
'-addext', 'basicConstraints=critical,CA:FALSE',
]
if args.type == 'server':
sans = [f'DNS:{args.subject}'] + [_san_prefix(s) for s in args.san]
req_args += [
'-addext', 'keyUsage=critical,digitalSignature,keyEncipherment',
'-addext', 'extendedKeyUsage=serverAuth,clientAuth',
'-addext', f'subjectAltName={",".join(sans)}',
]
else:
sans = [_san_prefix(s) for s in args.san]
req_args += [
'-addext', 'keyUsage=critical,digitalSignature,nonRepudiation',
'-addext', 'extendedKeyUsage=clientAuth,emailProtection',
]
if sans:
req_args += ['-addext', f'subjectAltName={",".join(sans)}']
x509_args = [
'openssl', 'x509',
'-req',
'-CA', str(CA_CERT),
'-CAkey', str(CA_KEY),
'-copy_extensions', 'copyall',
'-days', str(args.days),
'-text',
'-out', str(cert_path),
]
csr = _run(*req_args)
_run(*x509_args, stdin=csr)
key_path.chmod(0o600)
print('Certificate created.')
print(f' Certificate : {cert_path}')
print(f' Private key : {key_path}')
def cmd_make_pfx(args):
name = args.name
cert_path = PKI_DIR / f'{name}_cert.pem'
key_path = PKI_DIR / f'{name}_key.pem'
pfx_path = PKI_DIR / f'{name}.pfx'
if not cert_path.exists() or not key_path.exists():
print(f'ERROR: Certificate or key not found for {name!r} in {PKI_DIR}.', file=sys.stderr)
sys.exit(1)
if pfx_path.exists():
print(f'ERROR: {pfx_path} already exists.', file=sys.stderr)
sys.exit(1)
password = args.password or 'changeit'
_run(
'openssl', 'pkcs12',
'-export',
'-out', str(pfx_path),
'-inkey', str(key_path),
'-in', str(cert_path),
'-certfile', str(CA_CERT),
'-password', f'pass:{password}',
)
print(f'PKCS#12 created: {pfx_path}')
def main():
ap = argparse.ArgumentParser(
prog='simple-ca',
description='Minimal CA for cloud-router IKEv2 PKI.',
)
sub = ap.add_subparsers(dest='command', required=True)
p_ca = sub.add_parser('make-ca', help='Create root CA')
p_ca.add_argument('name', help='CA common name')
p_ca.add_argument('--days', type=int, default=3650, metavar='N')
p_ca.set_defaults(func=cmd_make_ca)
p_cert = sub.add_parser('make-cert', help='Issue a certificate')
p_cert.add_argument('subject', help='Subject CN (FQDN for server, username for user)')
p_cert.add_argument('san', nargs='*', help='Additional SANs: IP, DNS, or email')
p_cert.add_argument('--type', choices=['server', 'user'], default='server',
help='Certificate type (default: server)')
p_cert.add_argument('--days', type=int, default=365, metavar='N')
p_cert.set_defaults(func=cmd_make_cert)
p_pfx = sub.add_parser('make-pfx', help='Export PKCS#12 bundle for a user certificate')
p_pfx.add_argument('name', help='Certificate name (without extension)')
p_pfx.add_argument('--password', metavar='PASS',
help='Export password (default: changeit)')
p_pfx.set_defaults(func=cmd_make_pfx)
args = ap.parse_args()
args.func(args)
if __name__ == '__main__':
main()
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/python3
"""Render cloud-router configuration files and configure the system."""
import os
import sys
import socket
import pathlib
import subprocess
import jinja2
TEMPLATE_DIR = pathlib.Path('/usr/share/cloud-router/templates')
def _require(name):
val = os.environ.get(name)
if val is None:
print(f'ERROR: environment variable {name} is not set', file=sys.stderr)
sys.exit(1)
return val
def build_context():
local_addrs = _require('CLOUD_ROUTER_LOCAL_ADDRS')
local_fqdn = _require('CLOUD_ROUTER_LOCAL_FQDN')
local_id_mode = _require('CLOUD_ROUTER_LOCAL_ID_MODE')
local_cidrs = _require('CLOUD_ROUTER_LOCAL_CIDRS')
remote_addrs = _require('CLOUD_ROUTER_REMOTE_ADDRS')
remote_id = _require('CLOUD_ROUTER_REMOTE_ID')
psk = _require('CLOUD_ROUTER_PSK')
remote_cidrs = _require('CLOUD_ROUTER_REMOTE_CIDRS')
router_int_gateway_ip = _require('CLOUD_ROUTER_ROUTER_INT_GATEWAY_IP')
p2s_address_pool = _require('CLOUD_ROUTER_P2S_ADDRESS_POOL')
wg_enabled = _require('CLOUD_ROUTER_WG_ENABLED')
wg_address = _require('CLOUD_ROUTER_WG_ADDRESS')
wg_listen_port = _require('CLOUD_ROUTER_WG_LISTEN_PORT')
local_subnet = local_cidrs.split(',')[0].strip()
p2s_server_name = local_fqdn.split('.')[0]
if local_id_mode == 'fqdn':
local_id = f'@{local_fqdn}'
elif local_id_mode == 'public_ip':
try:
local_id = socket.getaddrinfo(local_fqdn, None, socket.AF_INET)[0][4][0]
except socket.gaierror as exc:
print(f'ERROR: cannot resolve {local_fqdn}: {exc}', file=sys.stderr)
sys.exit(1)
elif local_id_mode == 'internal_ip':
local_id = local_addrs
else:
local_id = f'@{local_fqdn}'
return {
'local_addrs': local_addrs,
'local_fqdn': local_fqdn,
'local_id_mode': local_id_mode,
'local_cidrs': local_cidrs,
'local_subnet': local_subnet,
'remote_addrs': remote_addrs,
'remote_id': remote_id,
'psk': psk,
'remote_cidrs': remote_cidrs,
'router_int_gateway_ip': router_int_gateway_ip,
'p2s_address_pool': p2s_address_pool,
'p2s_server_name': p2s_server_name,
'wg_enabled': wg_enabled,
'wg_address': wg_address,
'wg_listen_port': wg_listen_port,
'local_id': local_id,
}
def render(jinja_env, ctx, template_name, dest, mode):
content = jinja_env.get_template(template_name).render(ctx)
dest = pathlib.Path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content, encoding='utf-8')
os.chmod(dest, mode)
os.chown(dest, 0, 0)
def detect_wan_iface():
result = subprocess.run(
['ip', 'route', 'get', '1.1.1.1'],
capture_output=True, text=True,
)
if result.returncode != 0:
print('ERROR: ip route get 1.1.1.1 failed', file=sys.stderr)
sys.exit(1)
tokens = result.stdout.split()
for i, tok in enumerate(tokens):
if tok == 'dev' and i + 1 < len(tokens):
return tokens[i + 1]
print('ERROR: unable to detect WAN interface', file=sys.stderr)
sys.exit(1)
def setup_wireguard(ctx):
if ctx['wg_enabled'] != 'true':
return
wg_dir = pathlib.Path('/etc/wireguard')
wg_dir.mkdir(mode=0o700, exist_ok=True)
key_file = wg_dir / 'wg0.key'
if not key_file.exists() or key_file.stat().st_size == 0:
result = subprocess.run(['wg', 'genkey'], capture_output=True, check=True)
key_file.write_bytes(result.stdout)
os.chmod(key_file, 0o600)
pub_result = subprocess.run(
['wg', 'pubkey'],
input=key_file.read_bytes(),
capture_output=True, check=True,
)
pub_file = wg_dir / 'wg0.pub'
pub_file.write_bytes(pub_result.stdout)
os.chmod(pub_file, 0o644)
def _insert_after(content, marker, block):
"""Insert block after the first line that exactly matches marker."""
lines = content.splitlines(keepends=True)
result = []
for line in lines:
result.append(line)
if line.rstrip('\n') == marker:
result.append(block)
return ''.join(result)
def _insert_after_first_commit(content, block):
"""Insert block after the first COMMIT line (end of *filter table)."""
lines = content.splitlines(keepends=True)
result = []
inserted = False
for line in lines:
result.append(line)
if not inserted and line.rstrip('\n') == 'COMMIT':
result.append(block)
inserted = True
return ''.join(result)
def _wg_block(ctx):
return (
'\n'
'# WIREGUARD RULES START\n'
f'-A ufw-before-input -p udp --dport {ctx["wg_listen_port"]} -j ACCEPT\n'
'# WIREGUARD RULES END\n'
)
def setup_ufw(ctx, wan_iface):
# ── DEFAULT_FORWARD_POLICY ────────────────────────────────────────────────
ufw_defaults = pathlib.Path('/etc/default/ufw')
if ufw_defaults.exists():
lines = ufw_defaults.read_text().splitlines(keepends=True)
lines = [
'DEFAULT_FORWARD_POLICY="ACCEPT"\n'
if ln.startswith('DEFAULT_FORWARD_POLICY=') else ln
for ln in lines
]
ufw_defaults.write_text(''.join(lines))
# ── before.rules ─────────────────────────────────────────────────────────
before_rules = pathlib.Path('/etc/ufw/before.rules')
if not before_rules.exists():
print(f'ERROR: {before_rules} does not exist', file=sys.stderr)
sys.exit(1)
content = before_rules.read_text()
# Idempotency: handle dpkg-reconfigure re-runs
if '# IPSEC RULES START' in content:
if ctx['wg_enabled'] == 'true' and '# WIREGUARD RULES START' not in content:
content = _insert_after(content, '# P2S DNS RULES END', _wg_block(ctx))
before_rules.write_text(content)
return
# Filter table additions (IPSEC, P2S DNS, FORWARD)
filter_block = (
'\n'
'# IPSEC RULES START\n'
'-A ufw-before-input -p udp --dport 500 -j ACCEPT\n'
'-A ufw-before-input -p udp --dport 4500 -j ACCEPT\n'
'-A ufw-before-input -p esp -j ACCEPT\n'
'-A ufw-before-input -m policy --dir in --pol ipsec -j ACCEPT\n'
'-A ufw-before-output -m policy --dir out --pol ipsec -j ACCEPT\n'
'-A ufw-before-forward -m policy --dir in --pol ipsec -j ACCEPT\n'
'-A ufw-before-forward -m policy --dir out --pol ipsec -j ACCEPT\n'
'# IPSEC RULES END\n'
'\n'
'# P2S DNS RULES START\n'
f'-A ufw-before-input -s {ctx["p2s_address_pool"]} -d {ctx["local_addrs"]} -p udp --dport 53 -j ACCEPT\n'
f'-A ufw-before-input -s {ctx["p2s_address_pool"]} -d {ctx["local_addrs"]} -p tcp --dport 53 -j ACCEPT\n'
'# P2S DNS RULES END\n'
'\n'
'# ROUTER FORWARD RULES START\n'
f'-A ufw-before-forward -s {ctx["local_subnet"]} -o {wan_iface} -j ACCEPT\n'
f'-A ufw-before-forward -d {ctx["local_subnet"]} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT\n'
'# ROUTER FORWARD RULES END\n'
)
content = _insert_after(content, '# End required lines', filter_block)
# NAT table (inserted after the filter table's COMMIT)
nat_lines = [
'\n# ROUTER NAT RULES START\n',
'*nat\n',
':POSTROUTING ACCEPT [0:0]\n',
'-F POSTROUTING\n',
]
for cidr in ctx['remote_cidrs'].split(','):
nat_lines.append(
f'-A POSTROUTING -s {ctx["local_subnet"]} -d {cidr.strip()} -j RETURN\n'
)
nat_lines.append(
f'-A POSTROUTING -s {ctx["local_subnet"]} -o {wan_iface} -j MASQUERADE\n'
)
nat_lines.append('COMMIT\n# ROUTER NAT RULES END\n')
content = _insert_after_first_commit(content, ''.join(nat_lines))
if ctx['wg_enabled'] == 'true':
content = _insert_after(content, '# P2S DNS RULES END', _wg_block(ctx))
before_rules.write_text(content)
def main():
ctx = build_context()
loader = jinja2.FileSystemLoader(str(TEMPLATE_DIR))
jinja_env = jinja2.Environment(
loader=loader,
keep_trailing_newline=True,
undefined=jinja2.StrictUndefined,
autoescape=False,
)
render(jinja_env, ctx, 'cloud-router.default.j2',
'/etc/default/cloud-router', 0o644)
render(jinja_env, ctx, 'remote-site.conf.j2',
'/etc/swanctl/conf.d/remote-site.conf', 0o600)
render(jinja_env, ctx, 'road-warrior.conf.j2',
'/etc/swanctl/conf.d/road-warrior.conf', 0o600)
render(jinja_env, ctx, 'p2s-forwarder.conf.j2',
'/etc/systemd/resolved.conf.d/p2s-forwarder.conf', 0o644)
render(jinja_env, ctx, '90-cloud-router.yaml.j2',
'/etc/netplan/90-cloud-router.yaml', 0o600)
if ctx['wg_enabled'] == 'true':
render(jinja_env, ctx, 'wg0.conf.j2',
'/etc/wireguard/wg0.conf', 0o600)
wan_iface = detect_wan_iface()
setup_wireguard(ctx)
setup_ufw(ctx, wan_iface)
if __name__ == '__main__':
main()
@@ -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 }}