Add cloud-router configuration templates and scripts

- Introduced debian templates for cloud-router configuration parameters.
- Added simple-ca.sh script for managing a minimal Certificate Authority (CA) for IKEv2 PKI.
- Created sysctl configuration to enable IP forwarding and adjust rp_filter settings.
- Implemented configure script to render configuration files using Jinja2 templates.
- Added simple-ca script for generating CA and certificates.
- Created Jinja2 templates for various configuration files including netplan, strongSwan, and WireGuard.
- Implemented UFW rules setup for IPsec and WireGuard.
- Added support for road-warrior and site-to-site VPN configurations.
This commit is contained in:
2026-05-27 00:33:07 +02:00
parent db78066d5c
commit 3c665c2b6c
22 changed files with 1676 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/python3
"""Render cloud-router configuration files and configure the system."""
import os
import sys
import socket
import pathlib
import subprocess
import jinja2
TEMPLATE_DIR = pathlib.Path('/usr/share/cloud-router/templates')
def _require(name):
val = os.environ.get(name)
if val is None:
print(f'ERROR: environment variable {name} is not set', file=sys.stderr)
sys.exit(1)
return val
def build_context():
local_addrs = _require('CLOUD_ROUTER_LOCAL_ADDRS')
local_fqdn = _require('CLOUD_ROUTER_LOCAL_FQDN')
local_id_mode = _require('CLOUD_ROUTER_LOCAL_ID_MODE')
local_cidrs = _require('CLOUD_ROUTER_LOCAL_CIDRS')
remote_addrs = _require('CLOUD_ROUTER_REMOTE_ADDRS')
remote_id = _require('CLOUD_ROUTER_REMOTE_ID')
psk = _require('CLOUD_ROUTER_PSK')
remote_cidrs = _require('CLOUD_ROUTER_REMOTE_CIDRS')
router_int_gateway_ip = _require('CLOUD_ROUTER_ROUTER_INT_GATEWAY_IP')
p2s_address_pool = _require('CLOUD_ROUTER_P2S_ADDRESS_POOL')
wg_enabled = _require('CLOUD_ROUTER_WG_ENABLED')
wg_address = _require('CLOUD_ROUTER_WG_ADDRESS')
wg_listen_port = _require('CLOUD_ROUTER_WG_LISTEN_PORT')
local_subnet = local_cidrs.split(',')[0].strip()
p2s_server_name = local_fqdn.split('.')[0]
if local_id_mode == 'fqdn':
local_id = f'@{local_fqdn}'
elif local_id_mode == 'public_ip':
try:
local_id = socket.getaddrinfo(local_fqdn, None, socket.AF_INET)[0][4][0]
except socket.gaierror as exc:
print(f'ERROR: cannot resolve {local_fqdn}: {exc}', file=sys.stderr)
sys.exit(1)
elif local_id_mode == 'internal_ip':
local_id = local_addrs
else:
local_id = f'@{local_fqdn}'
return {
'local_addrs': local_addrs,
'local_fqdn': local_fqdn,
'local_id_mode': local_id_mode,
'local_cidrs': local_cidrs,
'local_subnet': local_subnet,
'remote_addrs': remote_addrs,
'remote_id': remote_id,
'psk': psk,
'remote_cidrs': remote_cidrs,
'router_int_gateway_ip': router_int_gateway_ip,
'p2s_address_pool': p2s_address_pool,
'p2s_server_name': p2s_server_name,
'wg_enabled': wg_enabled,
'wg_address': wg_address,
'wg_listen_port': wg_listen_port,
'local_id': local_id,
}
def render(jinja_env, ctx, template_name, dest, mode):
content = jinja_env.get_template(template_name).render(ctx)
dest = pathlib.Path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content, encoding='utf-8')
os.chmod(dest, mode)
os.chown(dest, 0, 0)
def detect_wan_iface():
result = subprocess.run(
['ip', 'route', 'get', '1.1.1.1'],
capture_output=True, text=True,
)
if result.returncode != 0:
print('ERROR: ip route get 1.1.1.1 failed', file=sys.stderr)
sys.exit(1)
tokens = result.stdout.split()
for i, tok in enumerate(tokens):
if tok == 'dev' and i + 1 < len(tokens):
return tokens[i + 1]
print('ERROR: unable to detect WAN interface', file=sys.stderr)
sys.exit(1)
def setup_wireguard(ctx):
if ctx['wg_enabled'] != 'true':
return
wg_dir = pathlib.Path('/etc/wireguard')
wg_dir.mkdir(mode=0o700, exist_ok=True)
key_file = wg_dir / 'wg0.key'
if not key_file.exists() or key_file.stat().st_size == 0:
result = subprocess.run(['wg', 'genkey'], capture_output=True, check=True)
key_file.write_bytes(result.stdout)
os.chmod(key_file, 0o600)
pub_result = subprocess.run(
['wg', 'pubkey'],
input=key_file.read_bytes(),
capture_output=True, check=True,
)
pub_file = wg_dir / 'wg0.pub'
pub_file.write_bytes(pub_result.stdout)
os.chmod(pub_file, 0o644)
def _insert_after(content, marker, block):
"""Insert block after the first line that exactly matches marker."""
lines = content.splitlines(keepends=True)
result = []
for line in lines:
result.append(line)
if line.rstrip('\n') == marker:
result.append(block)
return ''.join(result)
def _insert_after_first_commit(content, block):
"""Insert block after the first COMMIT line (end of *filter table)."""
lines = content.splitlines(keepends=True)
result = []
inserted = False
for line in lines:
result.append(line)
if not inserted and line.rstrip('\n') == 'COMMIT':
result.append(block)
inserted = True
return ''.join(result)
def _wg_block(ctx):
return (
'\n'
'# WIREGUARD RULES START\n'
f'-A ufw-before-input -p udp --dport {ctx["wg_listen_port"]} -j ACCEPT\n'
'# WIREGUARD RULES END\n'
)
def setup_ufw(ctx, wan_iface):
# ── DEFAULT_FORWARD_POLICY ────────────────────────────────────────────────
ufw_defaults = pathlib.Path('/etc/default/ufw')
if ufw_defaults.exists():
lines = ufw_defaults.read_text().splitlines(keepends=True)
lines = [
'DEFAULT_FORWARD_POLICY="ACCEPT"\n'
if ln.startswith('DEFAULT_FORWARD_POLICY=') else ln
for ln in lines
]
ufw_defaults.write_text(''.join(lines))
# ── before.rules ─────────────────────────────────────────────────────────
before_rules = pathlib.Path('/etc/ufw/before.rules')
if not before_rules.exists():
print(f'ERROR: {before_rules} does not exist', file=sys.stderr)
sys.exit(1)
content = before_rules.read_text()
# Idempotency: handle dpkg-reconfigure re-runs
if '# IPSEC RULES START' in content:
if ctx['wg_enabled'] == 'true' and '# WIREGUARD RULES START' not in content:
content = _insert_after(content, '# P2S DNS RULES END', _wg_block(ctx))
before_rules.write_text(content)
return
# Filter table additions (IPSEC, P2S DNS, FORWARD)
filter_block = (
'\n'
'# IPSEC RULES START\n'
'-A ufw-before-input -p udp --dport 500 -j ACCEPT\n'
'-A ufw-before-input -p udp --dport 4500 -j ACCEPT\n'
'-A ufw-before-input -p esp -j ACCEPT\n'
'-A ufw-before-input -m policy --dir in --pol ipsec -j ACCEPT\n'
'-A ufw-before-output -m policy --dir out --pol ipsec -j ACCEPT\n'
'-A ufw-before-forward -m policy --dir in --pol ipsec -j ACCEPT\n'
'-A ufw-before-forward -m policy --dir out --pol ipsec -j ACCEPT\n'
'# IPSEC RULES END\n'
'\n'
'# P2S DNS RULES START\n'
f'-A ufw-before-input -s {ctx["p2s_address_pool"]} -d {ctx["local_addrs"]} -p udp --dport 53 -j ACCEPT\n'
f'-A ufw-before-input -s {ctx["p2s_address_pool"]} -d {ctx["local_addrs"]} -p tcp --dport 53 -j ACCEPT\n'
'# P2S DNS RULES END\n'
'\n'
'# ROUTER FORWARD RULES START\n'
f'-A ufw-before-forward -s {ctx["local_subnet"]} -o {wan_iface} -j ACCEPT\n'
f'-A ufw-before-forward -d {ctx["local_subnet"]} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT\n'
'# ROUTER FORWARD RULES END\n'
)
content = _insert_after(content, '# End required lines', filter_block)
# NAT table (inserted after the filter table's COMMIT)
nat_lines = [
'\n# ROUTER NAT RULES START\n',
'*nat\n',
':POSTROUTING ACCEPT [0:0]\n',
'-F POSTROUTING\n',
]
for cidr in ctx['remote_cidrs'].split(','):
nat_lines.append(
f'-A POSTROUTING -s {ctx["local_subnet"]} -d {cidr.strip()} -j RETURN\n'
)
nat_lines.append(
f'-A POSTROUTING -s {ctx["local_subnet"]} -o {wan_iface} -j MASQUERADE\n'
)
nat_lines.append('COMMIT\n# ROUTER NAT RULES END\n')
content = _insert_after_first_commit(content, ''.join(nat_lines))
if ctx['wg_enabled'] == 'true':
content = _insert_after(content, '# P2S DNS RULES END', _wg_block(ctx))
before_rules.write_text(content)
def main():
ctx = build_context()
loader = jinja2.FileSystemLoader(str(TEMPLATE_DIR))
jinja_env = jinja2.Environment(
loader=loader,
keep_trailing_newline=True,
undefined=jinja2.StrictUndefined,
autoescape=False,
)
render(jinja_env, ctx, 'cloud-router.default.j2',
'/etc/default/cloud-router', 0o644)
render(jinja_env, ctx, 'remote-site.conf.j2',
'/etc/swanctl/conf.d/remote-site.conf', 0o600)
render(jinja_env, ctx, 'road-warrior.conf.j2',
'/etc/swanctl/conf.d/road-warrior.conf', 0o600)
render(jinja_env, ctx, 'p2s-forwarder.conf.j2',
'/etc/systemd/resolved.conf.d/p2s-forwarder.conf', 0o644)
render(jinja_env, ctx, '90-cloud-router.yaml.j2',
'/etc/netplan/90-cloud-router.yaml', 0o600)
if ctx['wg_enabled'] == 'true':
render(jinja_env, ctx, 'wg0.conf.j2',
'/etc/wireguard/wg0.conf', 0o600)
wan_iface = detect_wan_iface()
setup_wireguard(ctx)
setup_ufw(ctx, wan_iface)
if __name__ == '__main__':
main()
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
# MIT License
#
# Copyright (c) 2026 Sławomir Koszewski
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""simple-ca — minimal CA for cloud-router IKEv2 PKI.
Directory layout:
/etc/swanctl/x509ca/ca.pem CA certificate (strongSwan trust anchor)
/etc/swanctl/private/ca.key CA private key
/etc/swanctl/x509/server.pem server certificate
/etc/swanctl/private/server.key server private key
/etc/cloud-router/pki/{name}_cert.pem user/road-warrior certificate
/etc/cloud-router/pki/{name}_key.pem user/road-warrior private key
/etc/cloud-router/pki/{name}.pfx PKCS#12 bundle for client distribution
"""
import argparse
import subprocess
import sys
from pathlib import Path
CA_CERT = Path('/etc/swanctl/x509ca/ca.pem')
CA_KEY = Path('/etc/swanctl/private/ca.key')
SERVER_CERT = Path('/etc/swanctl/x509/server.pem')
SERVER_KEY = Path('/etc/swanctl/private/server.key')
PKI_DIR = Path('/etc/cloud-router/pki')
def _run(*args, stdin=None):
result = subprocess.run(list(args), input=stdin, capture_output=True)
if result.returncode != 0:
sys.stderr.buffer.write(result.stderr)
sys.exit(1)
return result.stdout
def _san_prefix(s):
if '@' in s:
return f'email:{s}'
parts = s.split('.')
if len(parts) == 4 and all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
return f'IP:{s}'
return f'DNS:{s}'
def cmd_make_ca(args):
if CA_CERT.exists() and CA_KEY.exists():
print('Root CA already exists, skipping.')
return
print('Generating root CA certificate and key...')
_run(
'openssl', 'req',
'-x509',
'-newkey', 'rsa:4096',
'-keyout', str(CA_KEY),
'-out', str(CA_CERT),
'-days', str(args.days),
'-noenc',
'-subj', f'/CN={args.name}',
'-text',
'-addext', 'basicConstraints=critical,CA:TRUE,pathlen:1',
'-addext', 'keyUsage=critical,keyCertSign,cRLSign',
)
CA_KEY.chmod(0o600)
print(f'Root CA created.')
print(f' Certificate : {CA_CERT}')
print(f' Private key : {CA_KEY}')
def cmd_make_cert(args):
name = args.subject.split('.')[0]
if args.type == 'server':
cert_path = SERVER_CERT
key_path = SERVER_KEY
else:
cert_path = PKI_DIR / f'{name}_cert.pem'
key_path = PKI_DIR / f'{name}_key.pem'
if cert_path.exists() and key_path.exists():
print(f'Certificate already exists at {cert_path}, skipping.')
return
if not CA_CERT.exists() or not CA_KEY.exists():
print('ERROR: Root CA not found. Run: simple-ca make-ca <name>', file=sys.stderr)
sys.exit(1)
req_args = [
'openssl', 'req',
'-newkey', 'rsa:4096',
'-keyout', str(key_path),
'-noenc',
'-subj', f'/CN={args.subject}',
'-addext', 'basicConstraints=critical,CA:FALSE',
]
if args.type == 'server':
sans = [f'DNS:{args.subject}'] + [_san_prefix(s) for s in args.san]
req_args += [
'-addext', 'keyUsage=critical,digitalSignature,keyEncipherment',
'-addext', 'extendedKeyUsage=serverAuth,clientAuth',
'-addext', f'subjectAltName={",".join(sans)}',
]
else:
sans = [_san_prefix(s) for s in args.san]
req_args += [
'-addext', 'keyUsage=critical,digitalSignature,nonRepudiation',
'-addext', 'extendedKeyUsage=clientAuth,emailProtection',
]
if sans:
req_args += ['-addext', f'subjectAltName={",".join(sans)}']
x509_args = [
'openssl', 'x509',
'-req',
'-CA', str(CA_CERT),
'-CAkey', str(CA_KEY),
'-copy_extensions', 'copyall',
'-days', str(args.days),
'-text',
'-out', str(cert_path),
]
csr = _run(*req_args)
_run(*x509_args, stdin=csr)
key_path.chmod(0o600)
print('Certificate created.')
print(f' Certificate : {cert_path}')
print(f' Private key : {key_path}')
def cmd_make_pfx(args):
name = args.name
cert_path = PKI_DIR / f'{name}_cert.pem'
key_path = PKI_DIR / f'{name}_key.pem'
pfx_path = PKI_DIR / f'{name}.pfx'
if not cert_path.exists() or not key_path.exists():
print(f'ERROR: Certificate or key not found for {name!r} in {PKI_DIR}.', file=sys.stderr)
sys.exit(1)
if pfx_path.exists():
print(f'ERROR: {pfx_path} already exists.', file=sys.stderr)
sys.exit(1)
password = args.password or 'changeit'
_run(
'openssl', 'pkcs12',
'-export',
'-out', str(pfx_path),
'-inkey', str(key_path),
'-in', str(cert_path),
'-certfile', str(CA_CERT),
'-password', f'pass:{password}',
)
print(f'PKCS#12 created: {pfx_path}')
def main():
ap = argparse.ArgumentParser(
prog='simple-ca',
description='Minimal CA for cloud-router IKEv2 PKI.',
)
sub = ap.add_subparsers(dest='command', required=True)
p_ca = sub.add_parser('make-ca', help='Create root CA')
p_ca.add_argument('name', help='CA common name')
p_ca.add_argument('--days', type=int, default=3650, metavar='N')
p_ca.set_defaults(func=cmd_make_ca)
p_cert = sub.add_parser('make-cert', help='Issue a certificate')
p_cert.add_argument('subject', help='Subject CN (FQDN for server, username for user)')
p_cert.add_argument('san', nargs='*', help='Additional SANs: IP, DNS, or email')
p_cert.add_argument('--type', choices=['server', 'user'], default='server',
help='Certificate type (default: server)')
p_cert.add_argument('--days', type=int, default=365, metavar='N')
p_cert.set_defaults(func=cmd_make_cert)
p_pfx = sub.add_parser('make-pfx', help='Export PKCS#12 bundle for a user certificate')
p_pfx.add_argument('name', help='Certificate name (without extension)')
p_pfx.add_argument('--password', metavar='PASS',
help='Export password (default: changeit)')
p_pfx.set_defaults(func=cmd_make_pfx)
args = ap.parse_args()
args.func(args)
if __name__ == '__main__':
main()
@@ -0,0 +1,7 @@
network:
version: 2
ethernets:
eth1:
routes:
- to: {{ local_subnet }}
via: {{ router_int_gateway_ip }}
@@ -0,0 +1,14 @@
LOCAL_ADDRS="{{ local_addrs }}"
LOCAL_FQDN="{{ local_fqdn }}"
LOCAL_ID_MODE="{{ local_id_mode }}"
LOCAL_CIDRS="{{ local_cidrs }}"
LOCAL_SUBNET="{{ local_subnet }}"
REMOTE_ADDRS="{{ remote_addrs }}"
REMOTE_ID="{{ remote_id }}"
REMOTE_CIDRS="{{ remote_cidrs }}"
ROUTER_INT_GATEWAY_IP="{{ router_int_gateway_ip }}"
P2S_ADDRESS_POOL="{{ p2s_address_pool }}"
P2S_SERVER_NAME="{{ p2s_server_name }}"
WG_ENABLED="{{ wg_enabled }}"
WG_ADDRESS="{{ wg_address }}"
WG_LISTEN_PORT="{{ wg_listen_port }}"
@@ -0,0 +1,2 @@
[Resolve]
DNSStubListenerExtra={{ local_addrs }}
@@ -0,0 +1,46 @@
connections {
remote-site {
version = 2
proposals = aes256-sha256-modp2048,aes256-sha1-modp1024
reauth_time = 28800
unique = replace
local_addrs = {{ local_addrs }}
remote_addrs = {{ remote_addrs }}
local {
id = {{ local_id }}
auth = psk
}
remote {
id = @{{ remote_id }}
auth = psk
}
children {
site2site {
mode = tunnel
local_ts = {{ local_cidrs }}
remote_ts = {{ remote_cidrs }}
esp_proposals = aes256-sha256,aes256-sha1
life_time = 3600
dpd_action = restart
start_action = start
close_action = none
}
}
rekey_time = 10800
dpd_delay = 10
dpd_timeout = 120
}
}
secrets {
ike-psk {
id-1 = {{ local_id }}
id-2 = @{{ remote_id }}
secret = "{{ psk }}"
}
}
@@ -0,0 +1,43 @@
connections {
road-warrior {
version = 2
proposals = aes256-sha256-modp2048,aes256-sha1-modp1024
reauth_time = 28800
dpd_delay = 10
dpd_timeout = 120
local {
auth = pubkey
certs = server.pem
id = @{{ local_fqdn }}
}
remote {
auth = eap-tls
cacerts = ca.pem
eap_id = %any
}
children {
road-warrior {
mode = tunnel
local_ts = {{ local_cidrs }}
remote_ts = dynamic
esp_proposals = aes256-sha256,aes256-sha1
life_time = 3600
dpd_action = clear
start_action = none
close_action = none
}
}
pools = rw-pool
}
}
pools {
rw-pool {
addrs = {{ p2s_address_pool }}
dns = {{ local_addrs }}
}
}
@@ -0,0 +1,4 @@
[Interface]
PostUp = wg set %i private-key /etc/wireguard/wg0.key
ListenPort = {{ wg_listen_port }}
Address = {{ wg_address }}