Move OpenVPN and StrongSwan entrypoint to separate Docker images

This commit is contained in:
Lauri Võsandi 2021-06-02 15:44:19 +03:00
parent f5024edaec
commit a42db0219c
11 changed files with 171 additions and 258 deletions

View File

@ -1,4 +1,4 @@
FROM ubuntu:20.04 as base
FROM ubuntu:20.04
ENV container docker
ENV PYTHONUNBUFFERED=1
ENV LC_ALL C.UTF-8

View File

@ -1,4 +1,5 @@
include README.md
include pinecrypt/server/templates/*.conf
include pinecrypt/server/templates/mail/*.md
include pinecrypt/server/builder/overlay/usr/bin/pinecrypt.server-*
include pinecrypt/server/builder/overlay/etc/uci-defaults/*

View File

@ -1,15 +0,0 @@
#!/usr/bin/python3
import os
import sys
from pinecrypt.server import db
# This implements OCSP like functionality
obj = db.certificates.find_one({
# TODO: use digest instead
"serial_number": "%x" % int(os.environ["tls_serial_0"]),
"status":"signed",
})
if not obj:
sys.exit(1)

View File

@ -1,28 +0,0 @@
#!/usr/bin/python3
import os
import sys
from pinecrypt.server import db
from datetime import datetime
operation, addr = sys.argv[1:3]
if operation == "delete":
pass
else:
common_name = sys.argv[3]
db.certificates.update_one({
# TODO: use digest instead
"serial_number": "%x" % int(os.environ["tls_serial_0"]),
"status":"signed",
}, {
"$set": {
"last_seen": datetime.utcnow(),
"instance": os.environ["instance"],
"remote": {
"port": int(os.environ["untrusted_port"]),
"addr": os.environ["untrusted_ip"],
}
},
"$addToSet": {
"ip": addr
}
})

View File

@ -1,31 +0,0 @@
#!/usr/bin/python3
import sys
import os
from pinecrypt.server import db
from datetime import datetime
addrs = set()
for key, value in os.environ.items():
if key.startswith("PLUTO_PEER_SOURCEIP"):
addrs.add(value)
with open("/instance") as fh:
instance = fh.read().strip()
db.certificates.update_one({
"distinguished_name": os.environ["PLUTO_PEER_ID"],
"status":"signed",
}, {
"$set": {
"last_seen": datetime.utcnow(),
"instance": instance,
"remote": {
"addr": os.environ["PLUTO_PEER"]
}
},
"$addToSet": {
"ip": {
"$each": list(addrs)
}
}
})

View File

@ -169,7 +169,7 @@ def authenticate(optional=False):
user, passwd = b64decode(token).decode("utf-8").split(":", 1)
if "ldap" in const.AUTHENTICATION_BACKENDS:
upn = "%s@%s" % (user, const.KERBEROS_REALM)
upn = ("%s@%s" % (user, const.KERBEROS_REALM)).lower()
click.echo("Connecting to %s as %s" % (const.LDAP_AUTHENTICATION_URI, upn))
conn = ldap.initialize(const.LDAP_AUTHENTICATION_URI, bytes_mode=False)
conn.set_option(ldap.OPT_REFERRALS, 0)
@ -185,9 +185,9 @@ def authenticate(optional=False):
raise
except ldap.INVALID_CREDENTIALS:
logger.critical("LDAP bind authentication failed for user %s from %s",
repr(user), req.context["remote"]["addr"])
repr(upn), req.context["remote"]["addr"])
raise falcon.HTTPUnauthorized(
description="Please authenticate with %s domain account username" % const.DOMAIN,
description="Please authenticate with %s domain account username" % const.KERBEROS_REALM,
challenges=["Basic"])
req.context["ldap_conn"] = conn

View File

@ -81,6 +81,7 @@ def self_enroll(skip_notify=False):
cert, buf = sign(mongo_id=id, skip_notify=skip_notify, overwrite=True,
profile="Gateway", namespace=const.AUTHORITY_NAMESPACE)
os.umask(0o133)
with open(const.SELF_CERT_PATH + ".part", "wb") as fh:
fh.write(buf)
os.rename(const.SELF_CERT_PATH + ".part", const.SELF_CERT_PATH)

View File

@ -20,7 +20,9 @@ import pytz
from asn1crypto import pem, x509
from certbuilder import CertificateBuilder, pem_armor_certificate
from datetime import datetime, timedelta
from jinja2 import Environment, PackageLoader
from oscrypto import asymmetric
from math import log, ceil
from pinecrypt.server import const, mongolog, mailer, db
from pinecrypt.server.middleware import NormalizeMiddleware, PrometheusEndpoint
from pinecrypt.server.common import cn_to_dn, generate_serial
@ -340,8 +342,8 @@ def pinecone_provision():
with open(const.AUTHORITY_PRIVATE_KEY_PATH + ".part", "wb") as f:
f.write(obj["key"])
# Set permission bits to 640
os.umask(0o137)
# Set permission bits to 644
os.umask(0o133)
with open(const.AUTHORITY_CERTIFICATE_PATH + ".part", "wb") as f:
f.write(obj["cert"])
@ -371,6 +373,71 @@ def pinecone_provision():
}
})
# Separate pushed subnets by address family
push4 = set()
push6 = set()
for subnet in const.PUSH_SUBNETS:
if subnet.version == 4:
push4.add(subnet)
elif subnet.version == 6:
if not const.CLIENT_SUBNET6:
raise ValueError("Can't push IPv6 routes if no IPv6 client subnet is configured")
push6.add(subnet)
else:
raise NotImplementedError()
# Generate OpenVPN configurations
click.echo("Generating OpenVPN configuration files...")
from pinecrypt.server import config
ctx = {
"authority_namespace": const.AUTHORITY_NAMESPACE,
"push4": push4,
"push6": push6,
"openvpn_tls_version_min": config.get("Globals", "OPENVPN_TLS_VERSION_MIN")["value"],
"openvpn_tls_ciphersuites": config.get("Globals", "OPENVPN_TLS_CIPHERSUITES")["value"],
"openvpn_tls_cipher": config.get("Globals", "OPENVPN_TLS_CIPHER")["value"],
"openvpn_cipher": config.get("Globals", "OPENVPN_CIPHER")["value"],
"openvpn_auth": config.get("Globals", "OPENVPN_AUTH")["value"],
"strongswan_dhgroup": config.get("Globals", "STRONGSWAN_DHGROUP")["value"],
"strongswan_ike": config.get("Globals", "STRONGSWAN_IKE")["value"],
"strongswan_esp": config.get("Globals", "STRONGSWAN_ESP")["value"],
}
env = Environment(loader=PackageLoader("pinecrypt.server", "templates"))
os.umask(0o133)
d = ceil(log(const.CLIENT_SUBNET_SLOT_COUNT) / log(2))
for slot, proto in enumerate(["udp", "tcp"]):
ctx["proto"] = proto
ctx["slot4"] = list(const.CLIENT_SUBNET4.subnets(d))[slot]
ctx["slot6"] = list(const.CLIENT_SUBNET6.subnets(d))[slot] if const.CLIENT_SUBNET6 else None
with open("/server-secrets/openvpn-%s.conf" % proto, "w") as fh:
fh.write(env.get_template("openvpn.conf").render(ctx))
# Merged variants for StrongSwan
ctx["push"] = ctx["push4"].union(ctx["push6"])
# Generate StrongSwan config
click.echo("Generating StrongSwan configuration files...")
slot += 1
ctx["slot4"] = list(const.CLIENT_SUBNET4.subnets(d))[slot]
ctx["slot6"] = list(const.CLIENT_SUBNET6.subnets(d))[slot] if const.CLIENT_SUBNET6 else []
with open("/server-secrets/ipsec.conf", "w") as fh:
fh.write(env.get_template("ipsec.conf").render(ctx))
# Why do you do this StrongSwan?! You will parse the cert anyway,
# why do I need to distinguish ECDSA vs RSA in config?!
with open(const.SELF_CERT_PATH, "rb") as fh:
certificate_buf = fh.read()
header, _, certificate_der_bytes = pem.unarmor(certificate_buf)
certificate = x509.Certificate.load(certificate_der_bytes)
public_key = asymmetric.load_public_key(certificate["tbs_certificate"]["subject_public_key_info"])
with open("/server-secrets/ipsec.secrets", "w") as fh:
fh.write(": %s %s\n" % (
"ECDSA" if public_key.algorithm == "ec" else "RSA",
const.SELF_KEY_PATH
))
# TODO: use this task to send notification emails maybe?
click.echo("Finished starting up")
sleep(86400)
@ -491,173 +558,6 @@ def pinecone_session(): pass
def entry_point(): pass
@click.command("openvpn", help="Start OpenVPN server process")
@click.option("--local", "-l", default="0.0.0.0", help="OpenVPN listening address, defaults to all interfaces")
@click.option("--proto", "-t", default="udp", type=click.Choice(["udp", "tcp"]), help="OpenVPN transport protocol, UDP by default")
@click.option("--client-subnet-slot", "-s", type=int, help="Client subnet slot index")
@waitfile(const.SELF_CERT_PATH)
def pinecone_serve_openvpn(local, proto, client_subnet_slot):
from pinecrypt.server import config
# TODO: Generate (per-client configs) from MongoDB
executable = "/usr/sbin/openvpn"
args = executable,
slot4 = const.CLIENT_SUBNET4_SLOTS[client_subnet_slot]
args += "--server", str(slot4.network_address), str(slot4.netmask),
if const.CLIENT_SUBNET6:
args += "--server-ipv6", str(const.CLIENT_SUBNET6_SLOTS[client_subnet_slot]),
args += "--local", local
# Support only two modes TCP 443 and UDP 1194
if proto == "tcp":
args += "--dev", "tuntcp0",
args += "--port-share", "127.0.0.1", "1443",
args += "--proto", "tcp-server",
args += "--port", "443",
args += "--socket-flags", "TCP_NODELAY",
args += "--management", "127.0.0.1", "7506",
instance = "%s-openvpn-tcp-443" % const.FQDN
else:
args += "--dev", "tunudp0",
args += "--proto", "udp",
args += "--port", "1194",
args += "--management", "127.0.0.1", "7505",
instance = "%s-openvpn-udp-1194" % const.FQDN
args += "--setenv", "instance", instance
db.certificates.update_many({
"instance": instance
}, {
"$unset": {
"ip": "",
"instance": "",
}
})
# Send keep alive packets, mainly for UDP
args += "--keepalive", "60", "120",
args += "--opt-verify",
args += "--key", const.SELF_KEY_PATH
args += "--cert", const.SELF_CERT_PATH
args += "--ca", const.AUTHORITY_CERTIFICATE_PATH
if const.PUSH_SUBNETS:
args += "--push", "route-metric 1000"
for subnet in const.PUSH_SUBNETS:
if subnet.version == 4:
args += "--push", "route %s %s" % (subnet.network_address, subnet.netmask),
elif subnet.version == 6:
if not const.CLIENT_SUBNET6:
raise ValueError("Can't push IPv6 routes if no IPv6 client subnet is configured")
args += "--push", "route-ipv6 %s" % subnet
else:
raise NotImplementedError()
# TODO: Figure out how to do dhparam without blocking initially
if os.path.exists(const.DHPARAM_PATH):
args += "--dh", const.DHPARAM_PATH
else:
args += "--dh", "none"
# For more info see: openvpn --show-tls
args += "--tls-version-min", config.get("Globals", "OPENVPN_TLS_VERSION_MIN")["value"]
args += "--tls-ciphersuites", config.get("Globals", "OPENVPN_TLS_CIPHERSUITES")["value"], # Used by TLS 1.3
args += "--tls-cipher", config.get("Globals", "OPENVPN_TLS_CIPHER")["value"], # Used by TLS 1.2
# Data channel encryption parameters
# TODO: Rename to --data-cipher when OpenVPN 2.5 becomes available
args += "--cipher", config.get("Globals", "OPENVPN_CIPHER")["value"]
args += "--auth", config.get("Globals", "OPENVPN_AUTH")["value"]
# Just to sanity check ourselves
args += "--tls-cert-profile", "preferred",
# Disable cipher negotiation since we know what we want
args += "--ncp-disable",
args += "--script-security", "2",
args += "--learn-address", "/helpers/openvpn-learn-address.py"
args += "--client-connect", "/helpers/openvpn-client-connect.py"
args += "--verb", "0",
logger.info("Executing: %s" % (" ".join(args)))
os.execv(executable, args)
@click.command("strongswan", help="Start StrongSwan")
@click.option("--client-subnet-slot", "-s", type=int, help="Client subnet slot index")
@waitfile(const.SELF_CERT_PATH)
def pinecone_serve_strongswan(client_subnet_slot):
from pinecrypt.server import config
slots = []
slots.append(const.CLIENT_SUBNET4_SLOTS[client_subnet_slot])
if const.CLIENT_SUBNET6:
slots.append(const.CLIENT_SUBNET6_SLOTS[client_subnet_slot])
with open("/etc/ipsec.conf", "w") as fh:
fh.write("config setup\n")
fh.write(" strictcrlpolicy=yes\n")
fh.write(" charondebug=\"cfg 2\"\n")
fh.write("\n")
fh.write("ca authority\n")
fh.write(" auto=add\n")
fh.write(" cacert=%s\n" % const.AUTHORITY_CERTIFICATE_PATH)
fh.write("\n")
fh.write("conn s2c\n")
fh.write(" auto=add\n")
fh.write(" keyexchange=ikev2\n")
fh.write(" left=%s\n" % const.AUTHORITY_NAMESPACE)
fh.write(" leftsendcert=always\n")
fh.write(" leftallowany=yes\n") # For load-balancing
fh.write(" leftcert=%s\n" % const.SELF_CERT_PATH)
if const.PUSH_SUBNETS:
fh.write(" leftsubnet=%s\n" % ",".join([str(j) for j in const.PUSH_SUBNETS]))
fh.write(" leftupdown=/helpers/updown.py\n")
fh.write(" right=%any\n")
fh.write(" rightsourceip=%s\n" % ",".join([str(j) for j in slots]))
fh.write(" ike=%s!\n" % config.get("Globals", "STRONGSWAN_IKE")["value"])
fh.write(" esp=%s!\n" % config.get("Globals", "STRONGSWAN_ESP")["value"])
with open("/etc/ipsec.conf") as fh:
print(fh.read())
# Why do you do this StrongSwan?! You will parse the cert anyway,
# why do I need to distinguish ECDSA vs RSA in config?!
with open(const.SELF_CERT_PATH, "rb") as fh:
certificate_buf = fh.read()
header, _, certificate_der_bytes = pem.unarmor(certificate_buf)
certificate = x509.Certificate.load(certificate_der_bytes)
public_key = asymmetric.load_public_key(certificate["tbs_certificate"]["subject_public_key_info"])
with open("/etc/ipsec.secrets", "w") as fh:
fh.write(": %s %s\n" % (
"ECDSA" if public_key.algorithm == "ec" else "RSA",
const.SELF_KEY_PATH
))
executable = "/usr/sbin/ipsec"
args = executable, "start", "--nofork"
logger.info("Executing: %s" % (" ".join(args)))
instance = "%s-ipsec" % const.FQDN
db.certificates.update_many({
"instance": instance
}, {
"$unset": {
"ip": "",
"instance": "",
}
})
# TODO: Find better way to push env vars to updown script
with open("/instance", "w") as fh:
fh.write(instance)
os.execv(executable, args)
pinecone_serve.add_command(pinecone_serve_openvpn)
pinecone_serve.add_command(pinecone_serve_strongswan)
pinecone_serve.add_command(pinecone_serve_backend)
pinecone_serve.add_command(pinecone_serve_ocsp_responder)
pinecone_serve.add_command(pinecone_serve_events)

View File

@ -6,7 +6,6 @@ import socket
import sys
from datetime import timedelta
from ipaddress import ip_network
from math import log, ceil
RE_USERNAME = r"^[a-z][a-z0-9]+$"
RE_FQDN = r"^(([a-z0-9]|[a-z0-9][a-z0-9\-_]*[a-z0-9])\.)+([a-z0-9]|[a-z0-9][a-z0-9\-_]*[a-z0-9])?$"
@ -46,11 +45,11 @@ CURVE_NAME = "secp384r1"
# Kerberos-like clock skew tolerance
CLOCK_SKEW_TOLERANCE = timedelta(minutes=5)
AUTHORITY_PRIVATE_KEY_PATH = "/var/lib/certidude/authority-secrets/ca_key.pem"
AUTHORITY_CERTIFICATE_PATH = "/var/lib/certidude/server-secrets/ca_cert.pem"
SELF_CERT_PATH = "/var/lib/certidude/server-secrets/self_cert.pem"
SELF_KEY_PATH = "/var/lib/certidude/server-secrets/self_key.pem"
DHPARAM_PATH = "/var/lib/certidude/server-secrets/dhparam.pem"
AUTHORITY_PRIVATE_KEY_PATH = "/authority-secrets/ca_key.pem"
AUTHORITY_CERTIFICATE_PATH = "/server-secrets/ca_cert.pem"
SELF_CERT_PATH = "/server-secrets/self_cert.pem"
SELF_KEY_PATH = "/server-secrets/self_key.pem"
DHPARAM_PATH = "/server-secrets/dhparam.pem"
BUILDER_TARBALLS = ""
FQDN = socket.getfqdn()
@ -114,7 +113,7 @@ TOKEN_OVERWRITE_PERMITTED = os.getenv("TOKEN_OVERWRITE_PERMITTED")
AUTHENTICATION_BACKENDS = set(["ldap"])
MAIL_SUFFIX = os.getenv("MAIL_SUFFIX")
KERBEROS_KEYTAB = os.getenv("KERBEROS_KEYTAB", "/var/lib/certidude/server-secrets/krb5.keytab")
KERBEROS_KEYTAB = os.getenv("KERBEROS_KEYTAB", "/server-secrets/krb5.keytab")
KERBEROS_REALM = os.getenv("KERBEROS_REALM")
LDAP_AUTHENTICATION_URI = os.getenv("LDAP_AUTHENTICATION_URI")
LDAP_GSSAPI_CRED_CACHE = os.getenv("LDAP_GSSAPI_CRED_CACHE", "/run/certidude/krb5cc")
@ -158,13 +157,9 @@ REQUEST_SUBMISSION_ALLOWED = os.getenv("REQUEST_SUBMISSION_ALLOWED")
REVOCATION_LIST_LIFETIME = os.getenv("REVOCATION_LIST_LIFETIME")
PUSH_SUBNETS = [ip_network(j) for j in os.getenv("PUSH_SUBNETS", "").replace(" ", ",").split(",") if j]
CLIENT_SUBNET4 = ip_network(os.getenv("CLIENT_SUBNET4", "192.168.33.0/24"))
CLIENT_SUBNET6 = ip_network(os.getenv("CLIENT_SUBNET6")) if os.getenv("CLIENT_SUBNET6") else None
CLIENT_SUBNET_SLOT_COUNT = int(os.getenv("CLIENT_SUBNET_COUNT", 4))
divisions = ceil(log(CLIENT_SUBNET_SLOT_COUNT) / log(2))
CLIENT_SUBNET4_SLOTS = list(CLIENT_SUBNET4.subnets(divisions))
CLIENT_SUBNET6_SLOTS = list(CLIENT_SUBNET6.subnets(divisions)) if CLIENT_SUBNET6 else []
if CLIENT_SUBNET4.netmask == str("255.255.255.255"):
raise ValueError("Invalid client subnet specification: %s" % CLIENT_SUBNET4)

View File

@ -0,0 +1,21 @@
config setup
strictcrlpolicy=yes
charondebug="cfg 2"
ca authority
auto=add
cacert=/server-secrets/ca_cert.pem
conn s2c
auto=add
keyexchange=ikev2
left={{ authority_namespace }}
leftsendcert=always
leftallowany=yes
leftcert=/server-secrets/self_cert.pem
leftsubnet={% for subnet in push %}{{ subnet }},{% endfor %}
leftupdown=/helpers/updown.py
right=%any
rightsourceip={{ slot4 }}{% if slot6 %},{{ slot6 }}{% endif %}
ike={{ strongswan_ike }}!
esp={{ strongswan_esp }}!

View File

@ -0,0 +1,69 @@
{% if proto == "udp" %}
dev tun0
proto udp
port 1194
management 127.0.0.1 7505
setenv service openvpn-udp
{% else %}
dev tun1
port-share 127.0.0.1 1443
proto tcp-server
port 443
socket-flags TCP_NODELAY
management 127.0.0.1 7506
setenv service openvpn-tcp
{% endif %}
# Client subnets
server {{ slot4.network_address }} {{ slot4.netmask }}
{% if slot6 %}
server-ipv6 {{ slot6 }}
{% endif %}
topology subnet
# Bind to all interfaces
local 0.0.0.0
# Send keep alive packets, mainly for UDP
keepalive 60 120
opt-verify
# Keypairs
key /server-secrets/self_key.pem
cert /server-secrets/self_cert.pem
ca /server-secrets/ca_cert.pem
# Push subnets
{% if push %}
push "route-metric 10002
{% endif %}
{% for subnet in push4 %}
push "route {{ subnet.network_address }} {{ subnet.netmask }}"
{% endfor %}
{% for subnet in push6 %}
push "route-ipv6 {{ subnet }}"
{% endfor %}
# DH parameters file
dh none
#dhparam.pem
# Control channel encryption parameterss
# For more info see: openvpn --show-tls
tls-version-min {{ openvpn_tls_version_min }}
tls-ciphersuites {{ openvpn_tls_ciphersuites }} # Used by TLS 1.3
tls-cipher {{ openvpn_tls_cipher }} # Used by TLS 1.2
# Data channel encryption parameters
cipher {{ openvpn_cipher }}
auth {{ openvpn_auth }}
# Just to sanity check ourselves
tls-cert-profile preferred
script-security 2
learn-address /helpers/learn-address.py
client-connect /helpers/client-connect.py
#verb 0