diff --git a/dev-docs/howto/vpn/.helmignore b/dev-docs/howto/vpn/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/dev-docs/howto/vpn/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/dev-docs/howto/vpn/Chart.yaml b/dev-docs/howto/vpn/Chart.yaml new file mode 100644 index 000000000..6c66f70b9 --- /dev/null +++ b/dev-docs/howto/vpn/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: vpn +description: A VPN server for Constellation + +type: application + +version: 0.1.0 diff --git a/dev-docs/howto/vpn/README.md b/dev-docs/howto/vpn/README.md new file mode 100644 index 000000000..61fb7d0dd --- /dev/null +++ b/dev-docs/howto/vpn/README.md @@ -0,0 +1,36 @@ +# Constellation VPN + +This Helm chart deploys a VPN server to your Constellation cluster. + +## Installation + +1. Create and populate the configuration. + + ```sh + helm inspect values . >config.yaml + ``` + +2. Install the Helm chart. + + ```sh + helm install -f config.yaml vpn . + ``` + +3. Follow the post-installation instructions displayed by the CLI. + +## Architecture + +The VPN server is deployed as a `StatefulSet` to the cluster. It hosts the VPN frontend component, which is responsible for relaying traffic between the pod and the on-prem network, and the routing components that provide access to Constellation resources. The frontend supports IPSec and Wireguard. + +The VPN frontend is exposed with a public LoadBalancer to be accessible from the on-prem network. Traffic that reaches the VPN server pod is split into two categories: pod IPs and service IPs. + +The pod IP range is NATed with an iptables rule. On-prem worklaods can establish connections to a pod IP, but the Constellation workloads will see the client IP translated to that of the VPN frontend pod. + +The service IP range is handed to a transparent proxy running in the VPN frontend pod, which relays the connection to a backend pod. This is necessary because of the load-balancing mechanism of Cilium, which assumes service IP traffic to originate from the Constellation cluster itself. As for pod IP ranges, Constellation pods will only see the translated client address. + +## Limitations + +* Service IPs need to be proxied by the VPN frontend pod. This is a single point of failure, and it may become a bottleneck. +* IPs are NATed, so the Constellation pods won't see the real on-prem IPs. +* NetworkPolicy can't be applied selectively to the on-prem ranges. +* No connectivity from Constellation to on-prem workloads. diff --git a/dev-docs/howto/vpn/files/strongswan/charon-logging.conf b/dev-docs/howto/vpn/files/strongswan/charon-logging.conf new file mode 100644 index 000000000..7114f6ad1 --- /dev/null +++ b/dev-docs/howto/vpn/files/strongswan/charon-logging.conf @@ -0,0 +1,11 @@ +charon { + filelog { + stderr { + time_format = %b %e %T + ike_name = yes + default = 1 + ike = 2 + flush_line = yes + } + } +} diff --git a/dev-docs/howto/vpn/files/strongswan/entrypoint.sh b/dev-docs/howto/vpn/files/strongswan/entrypoint.sh new file mode 100644 index 000000000..aa0e75e6e --- /dev/null +++ b/dev-docs/howto/vpn/files/strongswan/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +# The charon binary is not included in the PATH generated by nixery.dev, find it manually. +charon="$(dirname "$(readlink -f "$(command -v charon-systemd)")")/../libexec/ipsec/charon" + +"${charon}" & + +while ! swanctl --stats > /dev/null 2> /dev/null; do + sleep 1 +done +swanctl --load-all + +wait diff --git a/dev-docs/howto/vpn/files/tproxy-setup.sh b/dev-docs/howto/vpn/files/tproxy-setup.sh new file mode 100644 index 000000000..adbfc272d --- /dev/null +++ b/dev-docs/howto/vpn/files/tproxy-setup.sh @@ -0,0 +1,38 @@ +#!/bin/sh + +set -eu + +### Pod IPs ### + +# Pod IPs are just NATed. + +iptables -t nat -N VPN_POST || iptables -t nat -F VPN_POST + +for cidr in ${VPN_PEER_CIDRS}; do + iptables -t nat -A VPN_POST -s "${cidr}" -d "${VPN_POD_CIDR}" -j MASQUERADE +done + +iptables -t nat -C POSTROUTING -j VPN_POST || iptables -t nat -A POSTROUTING -j VPN_POST + +### Service IPs ### + +# Service IPs need to be connected to locally to trigger the cgroup connect hook, thus we send them to the transparent proxy. + +# Packets with mark 1 are for tproxy and need to be delivered locally. +# For more information see: https://www.kernel.org/doc/Documentation/networking/tproxy.txt +pref=42 +table=42 +mark=0x1/0x1 +ip rule add pref "${pref}" fwmark "${mark}" lookup "${table}" +ip route replace local 0.0.0.0/0 dev lo table "${table}" + +iptables -t mangle -N VPN_PRE || iptables -t mangle -F VPN_PRE + +for cidr in ${VPN_PEER_CIDRS}; do + for proto in tcp udp; do + iptables -t mangle -A VPN_PRE -p "${proto}" -s "${cidr}" -d "${VPN_SERVICE_CIDR}" \ + -j TPROXY --tproxy-mark "${mark}" --on-port 61001 + done +done + +iptables -t mangle -C PREROUTING -j VPN_PRE || iptables -t mangle -A PREROUTING -j VPN_PRE diff --git a/dev-docs/howto/vpn/files/wireguard-setup.sh b/dev-docs/howto/vpn/files/wireguard-setup.sh new file mode 100644 index 000000000..f97696a93 --- /dev/null +++ b/dev-docs/howto/vpn/files/wireguard-setup.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +set -eu + +dev=vpn_wg0 + +ip link add dev "${dev}" type wireguard +wg setconf "${dev}" /etc/wireguard/wg.conf +ip link set dev "${dev}" up + +for cidr in ${VPN_PEER_CIDRS}; do + ip route replace "${cidr}" dev "${dev}" +done diff --git a/dev-docs/howto/vpn/templates/_helpers.tpl b/dev-docs/howto/vpn/templates/_helpers.tpl new file mode 100644 index 000000000..e090b562d --- /dev/null +++ b/dev-docs/howto/vpn/templates/_helpers.tpl @@ -0,0 +1,40 @@ + +{{- define "..name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 42 | trimSuffix "-" }} +{{- end }} + +{{- define "..fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 42 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 42 | trimSuffix "-" }} +{{- end }} +{{- end }} + +{{- define "..chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 42 | trimSuffix "-" }} +{{- end }} + +{{- define "..labels" -}} +helm.sh/chart: {{ include "..chart" . }} +{{ include "..selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "..selectorLabels" -}} +app.kubernetes.io/name: {{ include "..name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "..commonEnv" -}} +- name: VPN_PEER_CIDRS + value: {{ join " " .Values.peerCIDRs | quote }} +- name: VPN_POD_CIDR + value: {{ .Values.podCIDR | quote }} +- name: VPN_SERVICE_CIDR + value: {{ .Values.serviceCIDR | quote }} +{{- end }} diff --git a/dev-docs/howto/vpn/templates/configmaps.yaml b/dev-docs/howto/vpn/templates/configmaps.yaml new file mode 100644 index 000000000..7f20c6760 --- /dev/null +++ b/dev-docs/howto/vpn/templates/configmaps.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "..fullname" . }}-tproxy + labels: {{- include "..labels" . | nindent 4 }} +data: +{{ (.Files.Glob "files/tproxy-setup.sh").AsConfig | indent 2 }} +--- +{{- if .Values.wireguard.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "..fullname" . }}-wg + labels: {{- include "..labels" . | nindent 4 }} +data: +{{ (.Files.Glob "files/wireguard-setup.sh").AsConfig | indent 2 }} +{{- end }} +--- +{{ if .Values.ipsec.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "..fullname" . }}-strongswan + labels: {{- include "..labels" . | nindent 4 }} +data: +{{ (.Files.Glob "files/strongswan/*").AsConfig | indent 2 }} +{{- end }} diff --git a/dev-docs/howto/vpn/templates/secrets.yaml b/dev-docs/howto/vpn/templates/secrets.yaml new file mode 100644 index 000000000..9c050559b --- /dev/null +++ b/dev-docs/howto/vpn/templates/secrets.yaml @@ -0,0 +1,21 @@ +{{- if .Values.wireguard.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "..fullname" . }}-wg + labels: + {{- include "..labels" . | nindent 4 }} +data: + wg.conf: {{ include "wireguard.conf" . | b64enc }} +{{- end }} +--- +{{ if .Values.ipsec.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "..fullname" . }}-strongswan + labels: + {{- include "..labels" . | nindent 4 }} +data: + swanctl.conf: {{ include "strongswan.swanctl-conf" . | b64enc }} +{{- end }} diff --git a/dev-docs/howto/vpn/templates/service.yaml b/dev-docs/howto/vpn/templates/service.yaml new file mode 100644 index 000000000..6c4bc9755 --- /dev/null +++ b/dev-docs/howto/vpn/templates/service.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "..fullname" . }}-lb + labels: + {{- include "..labels" . | nindent 4 }} +spec: + type: LoadBalancer + selector: + {{- include "..selectorLabels" . | nindent 4 }} + component: frontend + externalTrafficPolicy: Local + ports: + {{- if .Values.ipsec.enabled }} + - name: isakmp + protocol: UDP + port: 500 + - name: ipsec-nat-t + protocol: UDP + port: 4500 + {{- end }} + {{- if .Values.wireguard.enabled }} + - name: wg + protocol: UDP + port: {{ .Values.wireguard.port }} + {{- end }} diff --git a/dev-docs/howto/vpn/templates/strongswan-secret.tpl b/dev-docs/howto/vpn/templates/strongswan-secret.tpl new file mode 100644 index 000000000..999a2d054 --- /dev/null +++ b/dev-docs/howto/vpn/templates/strongswan-secret.tpl @@ -0,0 +1,26 @@ +{{- define "strongswan.swanctl-conf" }} +connections { + net-net { + remote_addrs = {{ .Values.ipsec.peer }} + local { + auth = psk + } + remote { + auth = psk + } + children { + net-net { + local_ts = {{ .Values.podCIDR }},{{ .Values.serviceCIDR }} + remote_ts = {{ join "," .Values.peerCIDRs }} + start_action = trap + } + } + } +} + +secrets { + ike { + secret = {{ quote .Values.ipsec.psk }} + } +} +{{- end }} diff --git a/dev-docs/howto/vpn/templates/strongswan-statefulset.yaml b/dev-docs/howto/vpn/templates/strongswan-statefulset.yaml new file mode 100644 index 000000000..f619373f1 --- /dev/null +++ b/dev-docs/howto/vpn/templates/strongswan-statefulset.yaml @@ -0,0 +1,78 @@ +{{ if .Values.ipsec.enabled -}} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "..fullname" . }}-frontend + labels: {{- include "..labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "..selectorLabels" . | nindent 6 }} + component: frontend + template: + metadata: + labels: + {{- include "..selectorLabels" . | nindent 8 }} + component: frontend + spec: + hostNetwork: false + initContainers: + - name: tproxy-setup + image: nixery.dev/busybox/iptables + command: ["/bin/sh", "-x", "/entrypoint.sh"] + env: {{- include "..commonEnv" . | nindent 10 }} + securityContext: + capabilities: + add: ["NET_ADMIN"] + volumeMounts: + - name: tproxy-setup + mountPath: "/entrypoint.sh" + subPath: "tproxy-setup.sh" + readOnly: true + containers: + - name: tproxy + # Image source: github.com/burgerdev/go-tproxy + image: ghcr.io/burgerdev/go-tproxy:latest + command: ["/tproxy", "--port=61001", "--nat=true"] + securityContext: + capabilities: + add: ["NET_RAW"] + - name: strongswan + image: "nixery.dev/shell/strongswan" + command: ["/bin/sh", "-x", "/entrypoint.sh"] + securityContext: + capabilities: + add: ["NET_ADMIN"] + volumeMounts: + - name: strongswan + mountPath: "/entrypoint.sh" + subPath: "entrypoint.sh" + readOnly: true + - name: strongswan + mountPath: "/etc/strongswan.d/charon-logging.conf" + subPath: "charon-logging.conf" + readOnly: true + - name: strongswan + mountPath: "/etc/swanctl/swanctl.conf" + subPath: "swanctl.conf" + readOnly: true + volumes: + - name: tproxy-setup + configMap: + name: {{ include "..fullname" . }}-tproxy + - name: strongswan + projected: + sources: + - secret: + name: {{ include "..fullname" . }}-strongswan + items: + - key: swanctl.conf + path: swanctl.conf + - configMap: + name: {{ include "..fullname" . }}-strongswan + items: + - key: entrypoint.sh + path: entrypoint.sh + - key: charon-logging.conf + path: charon-logging.conf +{{- end }} diff --git a/dev-docs/howto/vpn/templates/wireguard-secret.tpl b/dev-docs/howto/vpn/templates/wireguard-secret.tpl new file mode 100644 index 000000000..99c23a7fe --- /dev/null +++ b/dev-docs/howto/vpn/templates/wireguard-secret.tpl @@ -0,0 +1,14 @@ +{{- define "wireguard.conf" }} +[Interface] +ListenPort = {{ .Values.wireguard.port }} +PrivateKey = {{ .Values.wireguard.private_key }} +[Peer] +PublicKey = {{ .Values.wireguard.peer_key }} +AllowedIPs = {{ join "," .Values.peerCIDRs }} +{{- if .Values.wireguard.endpoint }} +Endpoint = {{- .Values.wireguard.endpoint }} +{{- end }} +{{- if .Values.wireguard.keepAlive }} +PersistentKeepalive = {{- .Values.wireguard.keepAlive }} +{{- end }} +{{ end }} diff --git a/dev-docs/howto/vpn/templates/wireguard-statefulset.yaml b/dev-docs/howto/vpn/templates/wireguard-statefulset.yaml new file mode 100644 index 000000000..f39b05cc7 --- /dev/null +++ b/dev-docs/howto/vpn/templates/wireguard-statefulset.yaml @@ -0,0 +1,68 @@ +{{ if .Values.wireguard.enabled -}} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "..fullname" . }}-frontend + labels: {{- include "..labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "..selectorLabels" . | nindent 6 }} + component: frontend + template: + metadata: + labels: + {{- include "..selectorLabels" . | nindent 8 }} + component: frontend + spec: + hostNetwork: false + initContainers: + - name: tproxy-setup + image: nixery.dev/busybox/iptables + command: ["/bin/sh", "-x", "/entrypoint.sh"] + env: {{- include "..commonEnv" . | nindent 10 }} + securityContext: + capabilities: + add: ["NET_ADMIN"] + volumeMounts: + - name: tproxy-setup + mountPath: "/entrypoint.sh" + subPath: "tproxy-setup.sh" + readOnly: true + - name: wg-setup + image: "nixery.dev/busybox/wireguard-tools" + command: ["/bin/sh", "-x", "/etc/wireguard/wireguard-setup.sh"] + env: {{- include "..commonEnv" . | nindent 10 }} + securityContext: + capabilities: + add: ["NET_ADMIN"] + volumeMounts: + - name: wireguard + mountPath: "/etc/wireguard" + readOnly: true + containers: + - name: tproxy + # Image source: github.com/burgerdev/go-tproxy + image: ghcr.io/burgerdev/go-tproxy:latest + command: ["/tproxy", "--port=61001", "--nat=true"] + securityContext: + capabilities: + add: ["NET_RAW"] + volumes: + - name: tproxy-setup + configMap: + name: {{ include "..fullname" . }}-tproxy + - name: wireguard + projected: + sources: + - secret: + name: {{ include "..fullname" . }}-wg + items: + - key: wg.conf + path: wg.conf + - configMap: + name: {{ include "..fullname" . }}-wg + items: + - key: wireguard-setup.sh + path: wireguard-setup.sh +{{- end }} diff --git a/dev-docs/howto/vpn/values.yaml b/dev-docs/howto/vpn/values.yaml new file mode 100644 index 000000000..3d17833dc --- /dev/null +++ b/dev-docs/howto/vpn/values.yaml @@ -0,0 +1,39 @@ + +# Constellation Pod IP range to expose via VPN. The default is for GCP. +podCIDR: "10.10.0.0/16" + +# Constellation Service IPs to expose via VPN. The default is for GCP. +serviceCIDR: "10.96.0.0/12" + +# on-prem IP ranges to expose to Constellation. Must contain at least one CIDR. +peerCIDRs: [] + + +# The sections below configure the VPN connectivity to the Constellation +# cluster. Exactly one `enabled` must be set to true. + +# IPSec configuration +ipsec: + enabled: false + # pre-shared key used for authentication + psk: "" + # Address of the peer's gateway router. + peer: "" + +# Wireguard configuration +wireguard: + enabled: false + + # If Wireguard is enabled, these fields for the Constellation side must be populated. + private_key: "" + peer_key: "" + + # Listening port of the Constellation Wireguard. + port: 51820 + + # Optional host:port of the on-prem Wireguard. + endpoint: "" + + # Optional interval for keep-alive packets in seconds. Setting this helps the on-prem server to + # discover a restarted Constellation VPN frontend. + keepAlive: ""