Moved Debian package sources to a subdirectory of the repository.
This commit is contained in:
@@ -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/ \;
|
||||
Executable
+9
@@ -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/"
|
||||
@@ -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
|
||||
Executable
+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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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/
|
||||
Executable
+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
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
remove|deconfigure)
|
||||
systemctl disable --now strongswan || true
|
||||
;;
|
||||
esac
|
||||
|
||||
#DEBHELPER#
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/make -f
|
||||
%:
|
||||
dh $@
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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 }}
|
||||
Reference in New Issue
Block a user