├── .circleci └── config.yml ├── .dockerignore ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── build └── docker │ ├── operator.Dockerfile │ └── server.Dockerfile ├── cmd └── main.go ├── deploy ├── Chart.yaml ├── crds │ ├── ovpnclients.yaml │ └── ovpnservers.yaml ├── templates │ ├── _helpers.tpl │ ├── deployment.yaml │ ├── rbac.yaml │ ├── role-leader.yaml │ └── role.yaml └── values.yaml ├── go.mod ├── pkg ├── api │ └── v1alpha1 │ │ ├── common.go │ │ ├── common_defaults.go │ │ ├── meta.go │ │ ├── ovpnclient.go │ │ ├── ovpnclient_defaults.go │ │ ├── ovpnserver.go │ │ ├── ovpnserver_defaults.go │ │ └── zz_generated.deepcopy.go ├── controllers │ ├── ovpnclient.go │ ├── ovpnserver.go │ ├── ovpnserver │ │ ├── certificate.go │ │ ├── deployment.go │ │ └── service.go │ └── utils.go ├── crypto │ ├── dh.go │ ├── pki.go │ ├── tls.go │ └── vault.go └── ovpn │ ├── certificate.go │ ├── config.go │ ├── entrypoint.go │ ├── parse.go │ ├── static │ ├── client.go │ ├── config.go │ └── entrypoint.go │ └── template.go └── tests ├── env └── .gitkeep └── manifests ├── client.yaml └── server.yaml /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | executors: 4 | docker: 5 | docker: 6 | - image: circleci/buildpack-deps:stretch 7 | 8 | jobs: 9 | build-server-image: 10 | executor: docker 11 | steps: 12 | - checkout 13 | - setup_remote_docker 14 | - run: | 15 | docker build \ 16 | -t ghcr.io/borchero/meerkat/server:latest \ 17 | -f build/docker/server.Dockerfile . 18 | - run: docker save -o image.tar ghcr.io/borchero/meerkat/server:latest 19 | - persist_to_workspace: 20 | root: . 21 | paths: 22 | - ./image.tar 23 | 24 | build-operator-image: 25 | executor: docker 26 | steps: 27 | - checkout 28 | - setup_remote_docker 29 | - run: | 30 | docker build \ 31 | -t ghcr.io/borchero/meerkat/operator:latest \ 32 | -f build/docker/operator.Dockerfile . 33 | - run: docker save -o image.tar ghcr.io/borchero/meerkat/operator:latest 34 | - persist_to_workspace: 35 | root: . 36 | paths: 37 | - ./image.tar 38 | 39 | upload-server-image: 40 | executor: docker 41 | steps: 42 | - checkout 43 | - setup_remote_docker 44 | - attach_workspace: 45 | at: /tmp/workspace 46 | - run: docker load -i /tmp/workspace/image.tar 47 | - run: echo $DOCKER_PASSWORD | docker login ghcr.io -u $DOCKER_USERNAME --password-stdin 48 | - run: | 49 | docker tag \ 50 | ghcr.io/borchero/meerkat/server:latest \ 51 | ghcr.io/borchero/meerkat/server:${CIRCLE_TAG} 52 | - run: docker push ghcr.io/borchero/meerkat/server:latest 53 | - run: docker push ghcr.io/borchero/meerkat/server:${CIRCLE_TAG} 54 | 55 | upload-operator-image: 56 | executor: docker 57 | steps: 58 | - checkout 59 | - setup_remote_docker 60 | - attach_workspace: 61 | at: /tmp/workspace 62 | - run: docker load -i /tmp/workspace/image.tar 63 | - run: echo $DOCKER_PASSWORD | docker login ghcr.io -u $DOCKER_USERNAME --password-stdin 64 | - run: | 65 | docker tag \ 66 | ghcr.io/borchero/meerkat/operator:latest \ 67 | ghcr.io/borchero/meerkat/operator:${CIRCLE_TAG} 68 | - run: docker push ghcr.io/borchero/meerkat/operator:latest 69 | - run: docker push ghcr.io/borchero/meerkat/operator:${CIRCLE_TAG} 70 | 71 | upload-chart: 72 | docker: 73 | - image: alpine/git:latest 74 | steps: 75 | - checkout 76 | - run: | 77 | apk add --no-cache gettext 78 | 79 | cd .. 80 | git clone git@github.com:borchero/helm-charts.git 81 | 82 | export DST=helm-charts/charts/meerkat-operator@${CIRCLE_TAG} 83 | mv project/deploy ${DST} 84 | 85 | envsubst < ${DST}/values.yaml > ${DST}/values.subst.yaml 86 | mv ${DST}/values.subst.yaml ${DST}/values.yaml 87 | 88 | envsubst < ${DST}/Chart.yaml > ${DST}/Chart.subst.yaml 89 | mv ${DST}/Chart.subst.yaml ${DST}/Chart.yaml 90 | 91 | cd helm-charts 92 | git config user.name "circleci" 93 | git config user.email "noreply@borchero.com" 94 | git add . 95 | git commit -m "Update Charts" 96 | git push origin master 97 | 98 | workflows: 99 | version: 2 100 | deploy: 101 | jobs: 102 | - build-server-image: 103 | filters: 104 | tags: 105 | only: /.*/ 106 | - build-operator-image: 107 | filters: 108 | tags: 109 | only: /.*/ 110 | - upload-server-image: 111 | requires: 112 | - build-server-image 113 | filters: 114 | branches: 115 | ignore: /.*/ 116 | tags: 117 | only: /.*/ 118 | - upload-operator-image: 119 | requires: 120 | - build-operator-image 121 | filters: 122 | branches: 123 | ignore: /.*/ 124 | tags: 125 | only: /.*/ 126 | - upload-chart: 127 | requires: 128 | - upload-server-image 129 | - upload-operator-image 130 | filters: 131 | branches: 132 | ignore: /.*/ 133 | tags: 134 | only: /.*/ 135 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | go.sum 3 | /bin 4 | cmd/pkged.go 5 | tests/env/* 6 | !tests/env/.gitkeep 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Oliver Borchert 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | codegen: controller-gen 2 | $(CONTROLLER_GEN) \ 3 | object \ 4 | crd:trivialVersions=true \ 5 | rbac:roleName="\"{{ .Release.Name }}\"" \ 6 | paths="./..." \ 7 | output:crd:artifacts:config=deploy/crds \ 8 | output:rbac:artifacts:config=deploy/templates 9 | for file in deploy/crds/*.yaml; do \ 10 | mv $$file $$(echo $$file | sed -e 's/\(.*\)\/meerkat\.borchero\.com_\(.*\)$$/\1\/\2/g'); \ 11 | done 12 | 13 | #-------------------------------------------------------------------------------------------------- 14 | 15 | controller-gen: 16 | ifeq (, $(shell which controller-gen)) 17 | @{ \ 18 | set -e ;\ 19 | CONTROLLER_GEN_TMP_DIR=$$(mktemp -d) ;\ 20 | cd $$CONTROLLER_GEN_TMP_DIR ;\ 21 | go mod init tmp ;\ 22 | go get sigs.k8s.io/controller-tools/cmd/controller-gen@v0.3.0 ;\ 23 | rm -rf $$CONTROLLER_GEN_TMP_DIR ;\ 24 | } 25 | CONTROLLER_GEN=$(GOBIN)/controller-gen 26 | else 27 | CONTROLLER_GEN=$(shell which controller-gen) 28 | endif 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Meerkat 2 | 3 | Meerkat is a Kubernetes Operator that facilitates the deployment of OpenVPN in a Kubernetes 4 | cluster. By leveraging [Hashicorp Vault](https://www.vaultproject.io/), Meerkat securely manages 5 | the underlying PKI. 6 | 7 | ## Features 8 | 9 | Meerkat revolves around two CRDs, namely `OvpnServer` and `OvpnClient`. There may exist arbitrarily 10 | many servers while clients are always associated with a single server. These two CRDs give rise to 11 | the following features: 12 | 13 | - Generation of shared secrets for TLS Auth 14 | - Creation of a PKI for each server independently with secure private key 15 | - Dynamic OVPN server configuration 16 | - Rendering of `ovpn` client files for each client 17 | - Revocation of client certificates as an `OvpnClient` is deleted 18 | 19 | ## Usage 20 | 21 | This section gives a very brief overview of how Meerkat may be installed in your cluster. 22 | 23 | ### Prerequisites 24 | 25 | In order to use Meerkat, you must have access to a Vault instance. It requires the following: 26 | 27 | - Kubernetes Auth has to be enabled and a role for Meerkat has to be defined 28 | - A service account must be configured with a policy to manage PKIs at a specified path (and its 29 | subpaths). 30 | 31 | ### Operator Deployment 32 | 33 | Then, you can deploy the operator using Helm: 34 | 35 | ```bash 36 | helm repo add borchero https://charts.borchero.com 37 | helm install meerkat borchero/meerkat \ 38 | --set rbac.serviceAccountName=${SERVICE_ACCOUNT_NAME} \ 39 | --set vault.auth.config.role=${KUBERNETES_ROLE} \ 40 | --set vault.pkiPath=${PKI_PATH} 41 | ``` 42 | 43 | You can also leave all of these fields blank and they choose sensible defaults. Consult the 44 | [values file](./deploy/values.yaml) for further details. 45 | 46 | ### Custom Resources 47 | 48 | Once the operator is running, you can install the custom resources, creating a server and your 49 | clients. Have a look at the [example manifests](./tests/manifests). 50 | 51 | Once a client is created, there exists a secret with the client's name, containing the client's 52 | OVPN certificate. It can be retrieved by using `kubectl`: 53 | 54 | ```bash 55 | kubectl get secret -o json | jq -r '.data."certificate.ovpn"' | base64 -d 56 | ``` 57 | 58 | ## License 59 | 60 | Meerkat is licensed under the [MIT License](./LICENSE). 61 | -------------------------------------------------------------------------------- /build/docker/operator.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.15 as builder 2 | 3 | WORKDIR /app 4 | ENV CGO_ENABLED=0 \ 5 | GOOS=linux \ 6 | GOARCH=amd64 \ 7 | GO111MODULE=on 8 | 9 | COPY go.mod go.mod 10 | COPY cmd/main.go cmd/main.go 11 | COPY pkg pkg 12 | 13 | RUN go build -a -o operator cmd/main.go 14 | 15 | #-------------------------------------------------------------------------------------------------- 16 | 17 | FROM alpine:3.12 18 | 19 | RUN apk add --no-cache openvpn=2.4.9-r0 openssl=1.1.1i-r0 20 | COPY --from=builder /app/operator /operator 21 | 22 | ENTRYPOINT ["/operator"] 23 | -------------------------------------------------------------------------------- /build/docker/server.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.12 2 | 3 | RUN apk add --no-cache openvpn=2.4.9-r0 4 | ENTRYPOINT ["/app/entrypoint.sh"] 5 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | meerkatv1alpha1 "github.com/borchero/meerkat-operator/pkg/api/v1alpha1" 7 | "github.com/borchero/meerkat-operator/pkg/controllers" 8 | "github.com/borchero/meerkat-operator/pkg/crypto" 9 | vaultapi "github.com/hashicorp/vault/api" 10 | "github.com/kelseyhightower/envconfig" 11 | "go.uber.org/zap" 12 | "k8s.io/apimachinery/pkg/runtime" 13 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 14 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 15 | _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" 16 | ctrl "sigs.k8s.io/controller-runtime" 17 | ) 18 | 19 | type environment struct { 20 | Debug bool 21 | EnableLeaderElection bool `split_words:"true"` 22 | Server controllers.Config 23 | Vault crypto.VaultConfig 24 | } 25 | 26 | func main() { 27 | // Setup 28 | var env environment 29 | envconfig.MustProcess("", &env) 30 | 31 | var logger *zap.Logger 32 | var err error 33 | if env.Debug { 34 | logger, err = zap.NewDevelopment(zap.AddStacktrace(zap.FatalLevel)) 35 | } else { 36 | logger, err = zap.NewProduction(zap.AddStacktrace(zap.FatalLevel)) 37 | } 38 | if err != nil { 39 | panic(err) 40 | } 41 | 42 | // Configuration of Kubernetes 43 | scheme := runtime.NewScheme() 44 | utilruntime.Must(clientgoscheme.AddToScheme(scheme)) 45 | utilruntime.Must(meerkatv1alpha1.AddToScheme(scheme)) 46 | 47 | // Configure Vault 48 | config := vaultapi.DefaultConfig() 49 | config.Address = env.Vault.Addr 50 | if err := config.ConfigureTLS(&vaultapi.TLSConfig{ 51 | CACert: env.Vault.CaCrt, 52 | TLSServerName: env.Vault.ServerName, 53 | }); err != nil { 54 | panic(err) 55 | } 56 | vault, err := vaultapi.NewClient(config) 57 | if err != nil { 58 | panic(err) 59 | } 60 | go crypto.EnsureTokenUpdated( 61 | context.Background(), vault, env.Vault.TokenMount, logger.Named("vault"), 62 | ) 63 | 64 | // Setup manager 65 | mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ 66 | Scheme: scheme, 67 | MetricsBindAddress: ":8080", 68 | LeaderElection: env.EnableLeaderElection, 69 | LeaderElectionID: "meerkat.borchero.com", 70 | }) 71 | if err != nil { 72 | panic(err) 73 | } 74 | 75 | // Setup reconcilers 76 | controllers.MustSetupOvpnServerReconciler(env.Server, vault, mgr, logger.Named("ovpn-server")) 77 | controllers.MustSetupOvpnClientReconciler(env.Server, vault, mgr, logger.Named("ovpn-client")) 78 | 79 | // And run 80 | logger.Info("starting manager") 81 | if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { 82 | logger.Fatal("failed to run manager", zap.Error(err)) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /deploy/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | type: application 3 | name: meerkat 4 | version: ${CIRCLE_TAG} 5 | description: Kubernetes Operator for a Cloud-Native OpenVPN Deployment. 6 | maintainers: 7 | - name: Oliver Borchert 8 | email: borchero@icloud.com 9 | keywords: 10 | - operator 11 | - vpn 12 | - openvpn 13 | - security 14 | - vault 15 | -------------------------------------------------------------------------------- /deploy/crds/ovpnclients.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | apiVersion: apiextensions.k8s.io/v1 4 | kind: CustomResourceDefinition 5 | metadata: 6 | annotations: 7 | controller-gen.kubebuilder.io/version: v0.4.0 8 | creationTimestamp: null 9 | name: ovpnclients.meerkat.borchero.com 10 | spec: 11 | group: meerkat.borchero.com 12 | names: 13 | kind: OvpnClient 14 | listKind: OvpnClientList 15 | plural: ovpnclients 16 | singular: ovpnclient 17 | scope: Namespaced 18 | versions: 19 | - name: v1alpha1 20 | schema: 21 | openAPIV3Schema: 22 | description: OvpnClient defines the schema for an OVPN client. 23 | properties: 24 | apiVersion: 25 | description: 'APIVersion defines the versioned schema of this representation 26 | of an object. Servers should convert recognized schemas to the latest 27 | internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 28 | type: string 29 | kind: 30 | description: 'Kind is a string value representing the REST resource this 31 | object represents. Servers may infer this from the endpoint the client 32 | submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 33 | type: string 34 | metadata: 35 | type: object 36 | spec: 37 | description: OvpnClientSpec describes an OVPN client. 38 | properties: 39 | certificate: 40 | description: The certificate configuration. 41 | properties: 42 | rsaBits: 43 | default: 4096 44 | description: The number of bits to use for the root RSA key. Changing 45 | this value for existing keys (such as the root key) has no effect. 46 | enum: 47 | - 2048 48 | - 4096 49 | - 8192 50 | type: integer 51 | secretName: 52 | description: The name of the secret used to store the OVPN certificate. 53 | Defaults to the name of the client. 54 | type: string 55 | validity: 56 | description: The duration for which the certificate is valid. 57 | Defaults to 10 years for the root key, 90 days for the server 58 | and 2 years for client. 59 | type: string 60 | type: object 61 | commonName: 62 | description: The common name of the user. Typically a unique identifier 63 | such as the email address. 64 | type: string 65 | serverName: 66 | description: The name of the OvpnServer the client is associated with. 67 | The server must be in the same namespace as the client. 68 | type: string 69 | required: 70 | - commonName 71 | - serverName 72 | type: object 73 | status: 74 | description: OvpnClientStatus describes the status of an OVPN client. 75 | type: object 76 | required: 77 | - spec 78 | type: object 79 | served: true 80 | storage: true 81 | subresources: 82 | status: {} 83 | status: 84 | acceptedNames: 85 | kind: "" 86 | plural: "" 87 | conditions: [] 88 | storedVersions: [] 89 | -------------------------------------------------------------------------------- /deploy/crds/ovpnservers.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | apiVersion: apiextensions.k8s.io/v1 4 | kind: CustomResourceDefinition 5 | metadata: 6 | annotations: 7 | controller-gen.kubebuilder.io/version: v0.4.0 8 | creationTimestamp: null 9 | name: ovpnservers.meerkat.borchero.com 10 | spec: 11 | group: meerkat.borchero.com 12 | names: 13 | kind: OvpnServer 14 | listKind: OvpnServerList 15 | plural: ovpnservers 16 | singular: ovpnserver 17 | scope: Namespaced 18 | versions: 19 | - name: v1alpha1 20 | schema: 21 | openAPIV3Schema: 22 | description: OvpnServer defines the schema for the OVPN server. 23 | properties: 24 | apiVersion: 25 | description: 'APIVersion defines the versioned schema of this representation 26 | of an object. Servers should convert recognized schemas to the latest 27 | internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 28 | type: string 29 | kind: 30 | description: 'Kind is a string value representing the REST resource this 31 | object represents. Servers may infer this from the endpoint the client 32 | submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 33 | type: string 34 | metadata: 35 | type: object 36 | spec: 37 | description: OvpnServerSpec defines the configuration of an OVPN server. 38 | properties: 39 | deployment: 40 | description: The deployment configuration of the VPN server. 41 | properties: 42 | annotations: 43 | additionalProperties: 44 | type: string 45 | description: Custom annotations to set on the deployment. 46 | type: object 47 | entrypointConfigMapName: 48 | description: The name of the configmap to carry the OpenVPN setup. 49 | Defaults to `-entrypoint`. 50 | type: string 51 | name: 52 | description: The name of the deployment. Defaults to the name 53 | of the server. 54 | type: string 55 | ovpnConfigMapName: 56 | description: The name of the configmap to be used for the OpenVPN 57 | config. Defaults to `-config`. 58 | type: string 59 | podAnnotations: 60 | additionalProperties: 61 | type: string 62 | description: Custom annotations to set on the pod. 63 | type: object 64 | type: object 65 | network: 66 | description: The network configuration of the VPN server. 67 | properties: 68 | host: 69 | description: The host where the server is reachable at. Will also 70 | be used as the common name of the server certificate. 71 | type: string 72 | protocol: 73 | default: UDP 74 | description: The protocol used for the OVPN server. 75 | enum: 76 | - TCP 77 | - UDP 78 | type: string 79 | required: 80 | - host 81 | type: object 82 | secrets: 83 | description: The secrets used by the server. 84 | properties: 85 | crlName: 86 | description: The name of the secret containing the CRL. Defaults 87 | to `-crl`. 88 | type: string 89 | serverCertificateName: 90 | description: The name of the secret to use for the certificate 91 | used by the server. Defaults to `-server-certificate`. 92 | type: string 93 | sharedSecretName: 94 | description: The name for the secret to use for shared secrets 95 | (DH params and TLS auth). The default is `-shared-secrets`. 96 | type: string 97 | type: object 98 | security: 99 | description: The security configuration of the VPN server. 100 | properties: 101 | cipher: 102 | default: AES-256-GCM 103 | description: The TLS cipher to use. 104 | enum: 105 | - AES-256-GCM 106 | type: string 107 | clients: 108 | description: The default configuration for the client certificates. 109 | properties: 110 | rsaBits: 111 | default: 4096 112 | description: The number of bits to use for the root RSA key. 113 | Changing this value for existing keys (such as the root 114 | key) has no effect. 115 | enum: 116 | - 2048 117 | - 4096 118 | - 8192 119 | type: integer 120 | validity: 121 | description: The duration for which the certificate is valid. 122 | Defaults to 10 years for the root key, 90 days for the server 123 | and 2 years for client. 124 | type: string 125 | type: object 126 | diffieHellmanBits: 127 | default: 2048 128 | description: The number of bits to use for the Diffie Hellman 129 | parameters. 130 | enum: 131 | - 1024 132 | - 2048 133 | - 4096 134 | type: integer 135 | hmac: 136 | default: SHA-384 137 | description: The message digest algorithm to use. 138 | enum: 139 | - SHA-384 140 | type: string 141 | pki: 142 | description: The configuration of the PKI. 143 | properties: 144 | dn: 145 | description: The configuration for the distinguished name. 146 | properties: 147 | commonName: 148 | description: The common name for the PKI. 149 | type: string 150 | country: 151 | description: The country code. 152 | type: string 153 | locality: 154 | description: The location of the organization within the 155 | country. 156 | type: string 157 | organization: 158 | description: The name of the organization. 159 | type: string 160 | organizationalUnit: 161 | description: The unit within the defined organization. 162 | type: string 163 | type: object 164 | rsaBits: 165 | default: 4096 166 | description: The number of bits to use for the root RSA key. 167 | Changing this value for existing keys (such as the root 168 | key) has no effect. 169 | enum: 170 | - 2048 171 | - 4096 172 | - 8192 173 | type: integer 174 | validity: 175 | description: The duration for which the certificate is valid. 176 | Defaults to 10 years for the root key, 90 days for the server 177 | and 2 years for client. 178 | type: string 179 | type: object 180 | server: 181 | description: The configuration for the server certificates. 182 | properties: 183 | rsaBits: 184 | default: 4096 185 | description: The number of bits to use for the root RSA key. 186 | Changing this value for existing keys (such as the root 187 | key) has no effect. 188 | enum: 189 | - 2048 190 | - 4096 191 | - 8192 192 | type: integer 193 | validity: 194 | description: The duration for which the certificate is valid. 195 | Defaults to 10 years for the root key, 90 days for the server 196 | and 2 years for client. 197 | type: string 198 | type: object 199 | type: object 200 | service: 201 | description: The service configuration for the VPN server. 202 | properties: 203 | annotations: 204 | additionalProperties: 205 | type: string 206 | description: Custom annotations to set on the service. 207 | type: object 208 | name: 209 | description: The name of the service. Defaults to the name of 210 | the server. 211 | type: string 212 | port: 213 | default: 1194 214 | description: The port that the server should be running on. For 215 | `serviceType` set to `NodePort`, this value must be in the range 216 | [30000, 32767]. 217 | type: integer 218 | serviceType: 219 | default: LoadBalancer 220 | description: The type for the Kubernetes servce. 221 | enum: 222 | - LoadBalancer 223 | - NodePort 224 | type: string 225 | type: object 226 | traffic: 227 | description: The traffic configuration of the VPN server. 228 | properties: 229 | nameservers: 230 | default: 231 | - 8.8.4.4 232 | - 8.8.8.8 233 | description: Defines a list of nameservers to use for name resolution. 234 | items: 235 | description: IPv4Address defines an IPv4 address. 236 | format: ipv4 237 | type: string 238 | type: array 239 | redirectAll: 240 | default: false 241 | description: Whether all traffic should be routed through the 242 | VPN. 243 | type: boolean 244 | routes: 245 | description: Defines a list of (target) IP ranges for which traffic 246 | is routed through the VPN. Ignored if `redirectAll` is set. 247 | items: 248 | description: SubnetMask defines an IPv4 range in the form /. 249 | pattern: ^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/((3[0-2])|([1-2][0-9])|[1-9])$ 250 | type: string 251 | type: array 252 | type: object 253 | required: 254 | - network 255 | type: object 256 | status: 257 | description: OvpnServerStatus describes the status of an OVPN server. 258 | type: object 259 | required: 260 | - spec 261 | type: object 262 | served: true 263 | storage: true 264 | subresources: 265 | status: {} 266 | status: 267 | acceptedNames: 268 | kind: "" 269 | plural: "" 270 | conditions: [] 271 | storedVersions: [] 272 | -------------------------------------------------------------------------------- /deploy/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{- define "serviceAccount.name" -}} 2 | {{- if .Values.rbac.serviceAccountName -}} 3 | {{ .Values.rbac.serviceAccountName }} 4 | {{- else -}} 5 | {{ .Release.Name }} 6 | {{- end -}} 7 | {{- end -}} 8 | 9 | {{- define "vault.agent.args" -}} 10 | echo ${VAULT_CONFIG?} > /home/vault/config.json && vault agent -config=/home/vault/config.json 11 | {{- end -}} 12 | 13 | {{- define "vault.agent.config" -}} 14 | { 15 | "auto_auth": { 16 | "method": { 17 | "type": "{{ .Values.vault.auth.type }}", 18 | "mount_path": "{{ .Values.vault.auth.mountPath }}", 19 | "config": {{ toJson .Values.vault.auth.config }} 20 | }, 21 | "sink": [ 22 | { 23 | "type": "file", 24 | "config": { 25 | "path": "/home/vault/.vault-token" 26 | } 27 | } 28 | ] 29 | }, 30 | "exit_after_auth": false, 31 | "pid_file": "/home/vault/.pid", 32 | "vault": { 33 | {{- if .Values.vault.caCrt -}} 34 | "ca_cert": "{{ .Values.vault.caCrt }}", 35 | {{- end -}} 36 | "address": "{{ .Values.vault.address }}" 37 | }, 38 | "template": [ 39 | { 40 | "destination": "/vault/secrets/token", 41 | "contents": "{{ "{{" }} with secret \"auth/token/lookup-self\" {{ "}}" }}{{ "{{" }} .Data.id {{ "}}" }}\n{{ "{{" }} end {{ "}}" }}", 42 | "left_delimiter": "{{ "{{" }}", 43 | "right_delimiter": "{{ "}}" }}" 44 | } 45 | ] 46 | } 47 | {{- end -}} 48 | -------------------------------------------------------------------------------- /deploy/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ .Release.Name }} 5 | spec: 6 | replicas: 1 7 | strategy: 8 | type: Recreate 9 | selector: 10 | matchLabels: 11 | app.kubernetes.io/name: {{ .Release.Name }} 12 | 13 | template: 14 | metadata: 15 | labels: 16 | app.kubernetes.io/name: {{ .Release.Name }} 17 | spec: 18 | serviceAccountName: {{ include "serviceAccount.name" . }} 19 | containers: 20 | - name: operator 21 | image: {{ .Values.operator.image.name }}:{{ .Values.operator.image.tag }} 22 | env: 23 | {{ if .Values.operator.debug }} 24 | - name: DEBUG 25 | value: "true" 26 | {{ end }} 27 | - name: VAULT_ADDR 28 | value: {{ .Values.vault.address | quote }} 29 | {{ if .Values.vault.caCrt }} 30 | - name: VAULT_CA_CRT 31 | value: {{ .Values.vault.caCrt }} 32 | {{ end }} 33 | - name: VAULT_TOKEN_MOUNT 34 | value: /vault/secrets/token 35 | - name: SERVER_IMAGE 36 | value: {{ .Values.ovpn.image.name }}:{{ .Values.ovpn.image.tag }} 37 | - name: SERVER_PKI_PATH 38 | value: {{ .Values.vault.pkiPath }} 39 | volumeMounts: 40 | - name: agent-secrets 41 | mountPath: /vault/secrets 42 | 43 | - name: vault-agent 44 | image: {{ .Values.vault.agent.image.name }}:{{ .Values.vault.agent.image.tag }} 45 | command: ["/bin/sh", "-ec"] 46 | args: ["{{ include "vault.agent.args" . }}"] 47 | env: 48 | - name: VAULT_LOG_LEVEL 49 | value: info 50 | - name: VAULT_CONFIG 51 | value: | 52 | {{ include "vault.agent.config" . | indent 16 | trim }} 53 | volumeMounts: 54 | - name: agent-home 55 | mountPath: /home/vault 56 | - name: agent-secrets 57 | mountPath: /vault/secrets 58 | 59 | volumes: 60 | - name: agent-home 61 | emptyDir: 62 | medium: Memory 63 | - name: agent-secrets 64 | emptyDir: 65 | medium: Memory 66 | -------------------------------------------------------------------------------- /deploy/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: {{ include "serviceAccount.name" . }} 5 | 6 | --- 7 | apiVersion: rbac.authorization.k8s.io/v1 8 | kind: ClusterRoleBinding 9 | metadata: 10 | name: {{ .Release.Name }}-cluster-binding 11 | 12 | subjects: 13 | - kind: ServiceAccount 14 | name: {{ include "serviceAccount.name" . }} 15 | namespace: {{ .Release.Namespace }} 16 | 17 | roleRef: 18 | apiGroup: rbac.authorization.k8s.io 19 | kind: ClusterRole 20 | name: {{ .Release.Name }} 21 | 22 | --- 23 | apiVersion: rbac.authorization.k8s.io/v1 24 | kind: RoleBinding 25 | metadata: 26 | name: {{ .Release.Name }}-binding 27 | 28 | subjects: 29 | - kind: ServiceAccount 30 | name: {{ include "serviceAccount.name" . }} 31 | 32 | roleRef: 33 | apiGroup: rbac.authorization.k8s.io 34 | kind: Role 35 | name: {{ .Release.Name }}-leader 36 | -------------------------------------------------------------------------------- /deploy/templates/role-leader.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: Role 3 | metadata: 4 | name: {{ .Release.Name }}-leader 5 | 6 | rules: 7 | - apiGroups: [""] 8 | resources: ["configmaps"] 9 | verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] 10 | - apiGroups: [""] 11 | resources: ["configmaps/status"] 12 | verbs: ["get", "update", "patch"] 13 | - apiGroups: [""] 14 | resources: ["events"] 15 | verbs: ["create", "patch"] 16 | -------------------------------------------------------------------------------- /deploy/templates/role.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | kind: ClusterRole 5 | metadata: 6 | creationTimestamp: null 7 | name: '{{ .Release.Name }}' 8 | rules: 9 | - apiGroups: 10 | - apps 11 | resources: 12 | - deployments 13 | verbs: 14 | - create 15 | - delete 16 | - get 17 | - list 18 | - patch 19 | - update 20 | - watch 21 | - apiGroups: 22 | - "" 23 | resources: 24 | - configmaps 25 | - secrets 26 | - services 27 | verbs: 28 | - create 29 | - delete 30 | - get 31 | - list 32 | - patch 33 | - update 34 | - watch 35 | - apiGroups: 36 | - meerkat.borchero.com 37 | resources: 38 | - ovpnclients 39 | verbs: 40 | - create 41 | - delete 42 | - get 43 | - list 44 | - patch 45 | - update 46 | - watch 47 | - apiGroups: 48 | - meerkat.borchero.com 49 | resources: 50 | - ovpnservers 51 | verbs: 52 | - create 53 | - delete 54 | - get 55 | - list 56 | - patch 57 | - update 58 | - watch 59 | - apiGroups: 60 | - meerkat.borchero.com 61 | resources: 62 | - ovpnservers/status 63 | verbs: 64 | - get 65 | - patch 66 | - update 67 | -------------------------------------------------------------------------------- /deploy/values.yaml: -------------------------------------------------------------------------------- 1 | operator: 2 | image: 3 | name: ghcr.io/borchero/meerkat/operator 4 | tag: ${CIRCLE_TAG} 5 | debug: false 6 | 7 | ovpn: 8 | image: 9 | name: ghcr.io/borchero/meerkat/server 10 | tag: ${CIRCLE_TAG} 11 | 12 | vault: 13 | address: https://localhost:8200 14 | caCrt: ~ 15 | pkiPath: meerkat 16 | auth: 17 | type: kubernetes 18 | mountPath: auth/kubernetes 19 | config: 20 | role: meerkat 21 | agent: 22 | image: 23 | name: vault 24 | tag: 1.6.0 25 | 26 | rbac: 27 | serviceAccountName: ~ 28 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/borchero/meerkat-operator 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/Masterminds/sprig/v3 v3.2.0 7 | github.com/fsnotify/fsnotify v1.4.9 8 | github.com/hashicorp/vault/api v1.0.4 9 | github.com/kelseyhightower/envconfig v1.4.0 10 | go.uber.org/zap v1.15.0 11 | k8s.io/api v0.20.0 12 | k8s.io/apimachinery v0.20.0 13 | k8s.io/client-go v0.20.0 14 | sigs.k8s.io/controller-runtime v0.7.0 15 | ) 16 | -------------------------------------------------------------------------------- /pkg/api/v1alpha1/common.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 5 | ) 6 | 7 | // OvpnCertificateConfig describes common properties of certificate configurations. 8 | type OvpnCertificateConfig struct { 9 | // The duration for which the certificate is valid. Defaults to 10 years for the root key, 10 | // 90 days for the server and 2 years for client. 11 | Validity metav1.Duration `json:"validity,omitempty"` 12 | // The number of bits to use for the root RSA key. Changing this value for existing keys (such 13 | // as the root key) has no effect. 14 | // +kubebuilder:default=4096 15 | // +kubebuilder:validation:Enum=2048;4096;8192 16 | RSABits int `json:"rsaBits,omitempty"` 17 | } 18 | 19 | // OvpnPKICertificateConfig describes the certificate configuration of a PKI. 20 | type OvpnPKICertificateConfig struct { 21 | OvpnCertificateConfig `json:",inline"` 22 | } 23 | 24 | // OvpnServerCertificateConfig describes the certificate configuration of a server. 25 | type OvpnServerCertificateConfig struct { 26 | OvpnCertificateConfig `json:",inline"` 27 | } 28 | 29 | // OvpnClientCertificateConfig describes the certificate configuration of a client. 30 | type OvpnClientCertificateConfig struct { 31 | OvpnCertificateConfig `json:",inline"` 32 | } 33 | -------------------------------------------------------------------------------- /pkg/api/v1alpha1/common_defaults.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import "time" 4 | 5 | // DefaultedRSABits returns the number of RSA bits with a default of 4096. 6 | func (c OvpnCertificateConfig) DefaultedRSABits() int { 7 | if c.RSABits == 0 { 8 | return 4096 9 | } 10 | return c.RSABits 11 | } 12 | 13 | // DefaultedValidity returns the validity of the certificate with a default value of 10 years. 14 | func (c OvpnPKICertificateConfig) DefaultedValidity() time.Duration { 15 | if c.OvpnCertificateConfig.Validity.Duration == 0 { 16 | return 87600 * time.Hour 17 | } 18 | return c.OvpnCertificateConfig.Validity.Duration 19 | } 20 | 21 | // DefaultedValidity returns the validity of the certificate with a default value of 90 days. 22 | func (c OvpnServerCertificateConfig) DefaultedValidity() time.Duration { 23 | if c.OvpnCertificateConfig.Validity.Duration == 0 { 24 | return 2160 * time.Hour 25 | } 26 | return c.OvpnCertificateConfig.Validity.Duration 27 | } 28 | 29 | // DefaultedValidity returns the validity of the certificate with a default value of 2 years. 30 | func (c OvpnClientCertificateConfig) DefaultedValidity() time.Duration { 31 | if c.OvpnCertificateConfig.Validity.Duration == 0 { 32 | return 17520 * time.Hour 33 | } 34 | return c.OvpnCertificateConfig.Validity.Duration 35 | } 36 | -------------------------------------------------------------------------------- /pkg/api/v1alpha1/meta.go: -------------------------------------------------------------------------------- 1 | // Package v1alpha1 contains API Schema definitions for the meerkat v1alpha1 API group 2 | // +kubebuilder:object:generate=true 3 | // +groupName=meerkat.borchero.com 4 | package v1alpha1 5 | 6 | import ( 7 | "k8s.io/apimachinery/pkg/runtime/schema" 8 | "sigs.k8s.io/controller-runtime/pkg/scheme" 9 | ) 10 | 11 | var ( 12 | // GroupVersion is group version used to register these objects 13 | GroupVersion = schema.GroupVersion{Group: "meerkat.borchero.com", Version: "v1alpha1"} 14 | 15 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 16 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 17 | 18 | // AddToScheme adds the types in this group-version to the given scheme. 19 | AddToScheme = SchemeBuilder.AddToScheme 20 | ) 21 | -------------------------------------------------------------------------------- /pkg/api/v1alpha1/ovpnclient.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 5 | ) 6 | 7 | func init() { 8 | SchemeBuilder.Register(&OvpnClient{}, &OvpnClientList{}) 9 | } 10 | 11 | // OvpnClient defines the schema for an OVPN client. 12 | // +kubebuilder:object:root=true 13 | // +kubebuilder:subresource:status 14 | type OvpnClient struct { 15 | metav1.TypeMeta `json:",inline"` 16 | metav1.ObjectMeta `json:"metadata,omitempty"` 17 | 18 | Spec OvpnClientSpec `json:"spec"` 19 | Status OvpnClientStatus `json:"status,omitempty"` 20 | } 21 | 22 | // OvpnClientList defines the schema for a list of OVPN clients. 23 | // +kubebuilder:object:root=true 24 | type OvpnClientList struct { 25 | metav1.TypeMeta `json:",inline"` 26 | metav1.ListMeta `json:"metadata,omitempty"` 27 | 28 | Items []OvpnClient `json:"items"` 29 | } 30 | 31 | //------------------------------------------------------------------------------------------------- 32 | 33 | // OvpnClientSpec describes an OVPN client. 34 | type OvpnClientSpec struct { 35 | // The name of the OvpnServer the client is associated with. The server must be in the same 36 | // namespace as the client. 37 | ServerName string `json:"serverName"` 38 | // The common name of the user. Typically a unique identifier such as the email address. 39 | CommonName string `json:"commonName"` 40 | // The certificate configuration. 41 | Certificate OvpnClientCertificate `json:"certificate,omitempty"` 42 | } 43 | 44 | // OvpnClientCertificate describe the configuration of a OVPN client certificate. 45 | type OvpnClientCertificate struct { 46 | OvpnCertificateConfig `json:",inline"` 47 | // The name of the secret used to store the OVPN certificate. Defaults to the name of the 48 | // client. 49 | SecretName string `json:"secretName,omitempty"` 50 | } 51 | 52 | //------------------------------------------------------------------------------------------------- 53 | 54 | // OvpnClientStatus describes the status of an OVPN client. 55 | type OvpnClientStatus struct { 56 | } 57 | -------------------------------------------------------------------------------- /pkg/api/v1alpha1/ovpnclient_defaults.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 4 | 5 | // ObjectRefCertificateSecret returns a reference to the secret containing the OVPN certificate. 6 | func (c *OvpnClient) ObjectRefCertificateSecret() metav1.ObjectMeta { 7 | ref := metav1.ObjectMeta{ 8 | Name: c.Spec.Certificate.SecretName, 9 | Namespace: c.Namespace, 10 | } 11 | if ref.Name == "" { 12 | ref.Name = c.Name 13 | } 14 | return ref 15 | } 16 | -------------------------------------------------------------------------------- /pkg/api/v1alpha1/ovpnserver.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | corev1 "k8s.io/api/core/v1" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | ) 7 | 8 | func init() { 9 | SchemeBuilder.Register(&OvpnServer{}, &OvpnServerList{}) 10 | } 11 | 12 | // OvpnServer defines the schema for the OVPN server. 13 | // +kubebuilder:object:root=true 14 | // +kubebuilder:subresource:status 15 | type OvpnServer struct { 16 | metav1.TypeMeta `json:",inline"` 17 | metav1.ObjectMeta `json:"metadata,omitempty"` 18 | 19 | Spec OvpnServerSpec `json:"spec"` 20 | Status OvpnServerStatus `json:"status,omitempty"` 21 | } 22 | 23 | // OvpnServerList defines the schema for a list of OVPN servers. 24 | // +kubebuilder:object:root=true 25 | type OvpnServerList struct { 26 | metav1.TypeMeta `json:",inline"` 27 | metav1.ListMeta `json:"metadata,omitempty"` 28 | 29 | Items []OvpnServer `json:"items"` 30 | } 31 | 32 | //------------------------------------------------------------------------------------------------- 33 | 34 | // ServiceType defines the available types of Kubernetes service. 35 | // +kubebuilder:validation:Enum=LoadBalancer;NodePort 36 | type ServiceType string 37 | 38 | // SubnetMask defines an IPv4 range in the form /. 39 | // +kubebuilder:validation:Pattern="^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/((3[0-2])|([1-2][0-9])|[1-9])$" 40 | type SubnetMask string 41 | 42 | // IPv4Address defines an IPv4 address. 43 | // +kubebuilder:validation:Format=ipv4 44 | type IPv4Address string 45 | 46 | // Hmac defines a message digest algorithm. 47 | // +kubebuilder:validation:Enum=SHA-384 48 | type Hmac string 49 | 50 | // Cipher defines the TLS cipher to use. 51 | // +kubebuilder:validation:Enum=AES-256-GCM 52 | type Cipher string 53 | 54 | const ( 55 | // ServiceTypeLoadBalancer uses an external load balancer as entrypoint. 56 | ServiceTypeLoadBalancer ServiceType = "LoadBalancer" 57 | // ServiceTypeNodePort uses a port in the range 30000-32767 to expose the service. 58 | ServiceTypeNodePort ServiceType = "NodePort" 59 | 60 | // HmacSHA384 defines the SHA-384 message digest algorithm. 61 | HmacSHA384 Hmac = "SHA-384" 62 | 63 | // CipherAES256GCM defines the AES-256-GCM cipher. 64 | CipherAES256GCM Cipher = "AES-256-GCM" 65 | ) 66 | 67 | //------------------------------------------------------------------------------------------------- 68 | 69 | // OvpnServerSpec defines the configuration of an OVPN server. 70 | type OvpnServerSpec struct { 71 | // The network configuration of the VPN server. 72 | Network OvpnServerAddress `json:"network"` 73 | // The traffic configuration of the VPN server. 74 | Traffic OvpnTrafficConfig `json:"traffic,omitempty"` 75 | // The security configuration of the VPN server. 76 | Security OvpnSecurityConfig `json:"security,omitempty"` 77 | // The secrets used by the server. 78 | Secrets OvpnServerSecrets `json:"secrets,omitempty"` 79 | // The deployment configuration of the VPN server. 80 | Deployment OvpnServerDeployment `json:"deployment,omitempty"` 81 | // The service configuration for the VPN server. 82 | Service OvpnServerService `json:"service,omitempty"` 83 | } 84 | 85 | // OvpnServerAddress describes how the OVPN server may be reached. 86 | type OvpnServerAddress struct { 87 | // The host where the server is reachable at. Will also be used as the common name of the 88 | // server certificate. 89 | Host string `json:"host"` 90 | // The protocol used for the OVPN server. 91 | // +kubebuilder:default=UDP 92 | // +kubebuilder:validation:Enum=TCP;UDP 93 | Protocol corev1.Protocol `json:"protocol,omitempty"` 94 | } 95 | 96 | // OvpnTrafficConfig defines the configuration of how traffic flows through the VPN. 97 | type OvpnTrafficConfig struct { 98 | // Whether all traffic should be routed through the VPN. 99 | // +kubebuilder:default=false 100 | RedirectAll bool `json:"redirectAll,omitempty"` 101 | // Defines a list of (target) IP ranges for which traffic is routed through the VPN. Ignored if 102 | // `redirectAll` is set. 103 | Routes []SubnetMask `json:"routes,omitempty"` 104 | // Defines a list of nameservers to use for name resolution. 105 | // +kubebuilder:default={"8.8.4.4","8.8.8.8"} 106 | Nameservers []IPv4Address `json:"nameservers,omitempty"` 107 | } 108 | 109 | // OvpnSecurityConfig encapsulates security configuration of the OVPN server. 110 | type OvpnSecurityConfig struct { 111 | // The message digest algorithm to use. 112 | // +kubebuilder:default=SHA-384 113 | Hmac Hmac `json:"hmac,omitempty"` 114 | // The TLS cipher to use. 115 | // +kubebuilder:default=AES-256-GCM 116 | Cipher Cipher `json:"cipher,omitempty"` 117 | // The number of bits to use for the Diffie Hellman parameters. 118 | // +kubebuilder:default=2048 119 | // +kubebuilder:validation:Enum=1024;2048;4096 120 | DiffieHellmanBits int `json:"diffieHellmanBits,omitempty"` 121 | // The configuration of the PKI. 122 | PKI OvpnPkiConfig `json:"pki,omitempty"` 123 | // The configuration for the server certificates. 124 | Server OvpnServerCertificateConfig `json:"server,omitempty"` 125 | // The default configuration for the client certificates. 126 | Clients OvpnClientCertificateConfig `json:"clients,omitempty"` 127 | } 128 | 129 | // OvpnPkiConfig describes the how the PKI of the OVPN server should be constructed. 130 | type OvpnPkiConfig struct { 131 | OvpnPKICertificateConfig `json:",inline"` 132 | // The configuration for the distinguished name. 133 | DN OvpnPkiDnConfig `json:"dn,omitempty"` 134 | } 135 | 136 | // OvpnPkiDnConfig describes the configuration of the distinguished name. 137 | type OvpnPkiDnConfig struct { 138 | // The common name for the PKI. 139 | CommonName string `json:"commonName,omitempty"` 140 | // The name of the organization. 141 | Organization string `json:"organization,omitempty"` 142 | // The unit within the defined organization. 143 | OrganizationalUnit string `json:"organizationalUnit,omitempty"` 144 | // The country code. 145 | Country string `json:"country,omitempty"` 146 | // The location of the organization within the country. 147 | Locality string `json:"locality,omitempty"` 148 | } 149 | 150 | // OvpnServerSecrets describes the secrets that are stored in the cluster for an OVPN server. 151 | type OvpnServerSecrets struct { 152 | // The name for the secret to use for shared secrets (DH params and TLS auth). The default is 153 | // `-shared-secrets`. 154 | SharedSecretName string `json:"sharedSecretName,omitempty"` 155 | // The name of the secret to use for the certificate used by the server. Defaults to 156 | // `-server-certificate`. 157 | ServerCertificateName string `json:"serverCertificateName,omitempty"` 158 | // The name of the secret containing the CRL. Defaults to `-crl`. 159 | CrlName string `json:"crlName,omitempty"` 160 | } 161 | 162 | // OvpnServerDeployment describes the deployment configuration of the server. 163 | type OvpnServerDeployment struct { 164 | // The name of the deployment. Defaults to the name of the server. 165 | Name string `json:"name,omitempty"` 166 | // Custom annotations to set on the deployment. 167 | Annotations map[string]string `json:"annotations,omitempty"` 168 | // Custom annotations to set on the pod. 169 | PodAnnotations map[string]string `json:"podAnnotations,omitempty"` 170 | // The name of the configmap to be used for the OpenVPN config. Defaults to 171 | // `-config`. 172 | OvpnConfigMapName string `json:"ovpnConfigMapName,omitempty"` 173 | // The name of the configmap to carry the OpenVPN setup. Defaults to `-entrypoint`. 174 | EntrypointConfigMapName string `json:"entrypointConfigMapName,omitempty"` 175 | } 176 | 177 | // OvpnServerService describes the service configuration of the OVPN server. 178 | type OvpnServerService struct { 179 | // The name of the service. Defaults to the name of the server. 180 | Name string `json:"name,omitempty"` 181 | // Custom annotations to set on the service. 182 | Annotations map[string]string `json:"annotations,omitempty"` 183 | // The port that the server should be running on. For `serviceType` set to `NodePort`, this 184 | // value must be in the range [30000, 32767]. 185 | // +kubebuilder:default=1194 186 | Port uint16 `json:"port,omitempty"` 187 | // The type for the Kubernetes servce. 188 | // +kubebuilder:default=LoadBalancer 189 | // +kubebuilder:validation:Enum=LoadBalancer;NodePort 190 | ServiceType corev1.ServiceType `json:"serviceType,omitempty"` 191 | } 192 | 193 | //------------------------------------------------------------------------------------------------- 194 | 195 | // OvpnServerStatus describes the status of an OVPN server. 196 | type OvpnServerStatus struct { 197 | } 198 | -------------------------------------------------------------------------------- /pkg/api/v1alpha1/ovpnserver_defaults.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "fmt" 5 | 6 | corev1 "k8s.io/api/core/v1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | ) 9 | 10 | // ObjectRefSharedSecrets returns the reference to the shared secret. 11 | func (s OvpnServer) ObjectRefSharedSecrets() metav1.ObjectMeta { 12 | ref := metav1.ObjectMeta{ 13 | Name: s.Spec.Secrets.SharedSecretName, 14 | Namespace: s.Namespace, 15 | } 16 | if ref.Name == "" { 17 | ref.Name = fmt.Sprintf("%s-shared-secret", s.Name) 18 | } 19 | return ref 20 | } 21 | 22 | // ObjectRefCrlSecret returns a reference to the secret containing the CRL. 23 | func (s *OvpnServer) ObjectRefCrlSecret() metav1.ObjectMeta { 24 | ref := metav1.ObjectMeta{ 25 | Name: s.Spec.Secrets.CrlName, 26 | Namespace: s.Namespace, 27 | } 28 | if ref.Name == "" { 29 | ref.Name = fmt.Sprintf("%s-crl", s.Name) 30 | } 31 | return ref 32 | } 33 | 34 | // ObjectRefServerCertificateSecret returns a reference to the secret containing the server 35 | // certificate. 36 | func (s *OvpnServer) ObjectRefServerCertificateSecret() metav1.ObjectMeta { 37 | ref := metav1.ObjectMeta{ 38 | Name: s.Spec.Secrets.ServerCertificateName, 39 | Namespace: s.Namespace, 40 | } 41 | if ref.Name == "" { 42 | ref.Name = fmt.Sprintf("%s-server-certificate", s.Name) 43 | } 44 | return ref 45 | } 46 | 47 | // ObjectRefOvpnConfigMap returns a reference to the server configmap. 48 | func (s *OvpnServer) ObjectRefOvpnConfigMap() metav1.ObjectMeta { 49 | ref := metav1.ObjectMeta{ 50 | Name: s.Spec.Deployment.OvpnConfigMapName, 51 | Namespace: s.Namespace, 52 | } 53 | if ref.Name == "" { 54 | ref.Name = fmt.Sprintf("%s-config", s.Name) 55 | } 56 | return ref 57 | } 58 | 59 | // ObjectRefEntrypointConfigMap returns a reference to the server entrypoint configmap. 60 | func (s *OvpnServer) ObjectRefEntrypointConfigMap() metav1.ObjectMeta { 61 | ref := metav1.ObjectMeta{ 62 | Name: s.Spec.Deployment.EntrypointConfigMapName, 63 | Namespace: s.Namespace, 64 | } 65 | if ref.Name == "" { 66 | ref.Name = fmt.Sprintf("%s-entrypoint", s.Name) 67 | } 68 | return ref 69 | } 70 | 71 | // ObjectRefDeployment returns a reference to the deployment. 72 | func (s *OvpnServer) ObjectRefDeployment() metav1.ObjectMeta { 73 | ref := metav1.ObjectMeta{ 74 | Name: s.Spec.Deployment.Name, 75 | Namespace: s.Namespace, 76 | } 77 | if ref.Name == "" { 78 | ref.Name = s.Name 79 | } 80 | return ref 81 | } 82 | 83 | // ObjectRefService returns a reference to the service exposing the VPN. 84 | func (s *OvpnServer) ObjectRefService() metav1.ObjectMeta { 85 | ref := metav1.ObjectMeta{ 86 | Name: s.Spec.Service.Name, 87 | Namespace: s.Namespace, 88 | } 89 | if ref.Name == "" { 90 | ref.Name = s.Name 91 | } 92 | return ref 93 | } 94 | 95 | //------------------------------------------------------------------------------------------------- 96 | 97 | // DefaultedProtocol returns the provided protocol or UDP if none is provided. 98 | func (a OvpnServerAddress) DefaultedProtocol() corev1.Protocol { 99 | if a.Protocol == "" { 100 | return corev1.ProtocolUDP 101 | } 102 | return a.Protocol 103 | } 104 | 105 | // DefaultedNameservers returns the provided nameservers or standard Google nameservers otherwise. 106 | func (c OvpnTrafficConfig) DefaultedNameservers() []string { 107 | if c.Nameservers == nil || len(c.Nameservers) == 0 { 108 | return []string{"8.8.4.4", "8.8.8.8"} 109 | } 110 | result := []string{} 111 | for _, ip := range c.Nameservers { 112 | result = append(result, string(ip)) 113 | } 114 | return result 115 | } 116 | 117 | // DefaultedHmac returns the provided Hmac or SHA-384. 118 | func (c OvpnSecurityConfig) DefaultedHmac() Hmac { 119 | if c.Hmac == "" { 120 | return HmacSHA384 121 | } 122 | return c.Hmac 123 | } 124 | 125 | // DefaultedCipher returns the provided cipher or AES-256-GCM. 126 | func (c OvpnSecurityConfig) DefaultedCipher() Cipher { 127 | if c.Cipher == "" { 128 | return CipherAES256GCM 129 | } 130 | return c.Cipher 131 | } 132 | 133 | // DefaultedCommonName returns a default PKI common name if it is not defined. 134 | func (c OvpnPkiDnConfig) DefaultedCommonName() string { 135 | if c.CommonName == "" { 136 | return "ovpn-pki" 137 | } 138 | return c.CommonName 139 | } 140 | 141 | // DefaultedPort returns the port of the service. 142 | func (s OvpnServerService) DefaultedPort() uint16 { 143 | if s.Port == 0 { 144 | return 1194 145 | } 146 | return s.Port 147 | } 148 | 149 | // DefaultedServiceType returns the service type of the service. 150 | func (s OvpnServerService) DefaultedServiceType() corev1.ServiceType { 151 | if s.ServiceType == "" { 152 | return corev1.ServiceTypeLoadBalancer 153 | } 154 | return s.ServiceType 155 | } 156 | -------------------------------------------------------------------------------- /pkg/api/v1alpha1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | // +build !ignore_autogenerated 2 | 3 | // Code generated by controller-gen. DO NOT EDIT. 4 | 5 | package v1alpha1 6 | 7 | import ( 8 | runtime "k8s.io/apimachinery/pkg/runtime" 9 | ) 10 | 11 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 12 | func (in *OvpnCertificateConfig) DeepCopyInto(out *OvpnCertificateConfig) { 13 | *out = *in 14 | out.Validity = in.Validity 15 | } 16 | 17 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnCertificateConfig. 18 | func (in *OvpnCertificateConfig) DeepCopy() *OvpnCertificateConfig { 19 | if in == nil { 20 | return nil 21 | } 22 | out := new(OvpnCertificateConfig) 23 | in.DeepCopyInto(out) 24 | return out 25 | } 26 | 27 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 28 | func (in *OvpnClient) DeepCopyInto(out *OvpnClient) { 29 | *out = *in 30 | out.TypeMeta = in.TypeMeta 31 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 32 | out.Spec = in.Spec 33 | out.Status = in.Status 34 | } 35 | 36 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnClient. 37 | func (in *OvpnClient) DeepCopy() *OvpnClient { 38 | if in == nil { 39 | return nil 40 | } 41 | out := new(OvpnClient) 42 | in.DeepCopyInto(out) 43 | return out 44 | } 45 | 46 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 47 | func (in *OvpnClient) DeepCopyObject() runtime.Object { 48 | if c := in.DeepCopy(); c != nil { 49 | return c 50 | } 51 | return nil 52 | } 53 | 54 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 55 | func (in *OvpnClientCertificate) DeepCopyInto(out *OvpnClientCertificate) { 56 | *out = *in 57 | out.OvpnCertificateConfig = in.OvpnCertificateConfig 58 | } 59 | 60 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnClientCertificate. 61 | func (in *OvpnClientCertificate) DeepCopy() *OvpnClientCertificate { 62 | if in == nil { 63 | return nil 64 | } 65 | out := new(OvpnClientCertificate) 66 | in.DeepCopyInto(out) 67 | return out 68 | } 69 | 70 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 71 | func (in *OvpnClientCertificateConfig) DeepCopyInto(out *OvpnClientCertificateConfig) { 72 | *out = *in 73 | out.OvpnCertificateConfig = in.OvpnCertificateConfig 74 | } 75 | 76 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnClientCertificateConfig. 77 | func (in *OvpnClientCertificateConfig) DeepCopy() *OvpnClientCertificateConfig { 78 | if in == nil { 79 | return nil 80 | } 81 | out := new(OvpnClientCertificateConfig) 82 | in.DeepCopyInto(out) 83 | return out 84 | } 85 | 86 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 87 | func (in *OvpnClientList) DeepCopyInto(out *OvpnClientList) { 88 | *out = *in 89 | out.TypeMeta = in.TypeMeta 90 | in.ListMeta.DeepCopyInto(&out.ListMeta) 91 | if in.Items != nil { 92 | in, out := &in.Items, &out.Items 93 | *out = make([]OvpnClient, len(*in)) 94 | for i := range *in { 95 | (*in)[i].DeepCopyInto(&(*out)[i]) 96 | } 97 | } 98 | } 99 | 100 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnClientList. 101 | func (in *OvpnClientList) DeepCopy() *OvpnClientList { 102 | if in == nil { 103 | return nil 104 | } 105 | out := new(OvpnClientList) 106 | in.DeepCopyInto(out) 107 | return out 108 | } 109 | 110 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 111 | func (in *OvpnClientList) DeepCopyObject() runtime.Object { 112 | if c := in.DeepCopy(); c != nil { 113 | return c 114 | } 115 | return nil 116 | } 117 | 118 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 119 | func (in *OvpnClientSpec) DeepCopyInto(out *OvpnClientSpec) { 120 | *out = *in 121 | out.Certificate = in.Certificate 122 | } 123 | 124 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnClientSpec. 125 | func (in *OvpnClientSpec) DeepCopy() *OvpnClientSpec { 126 | if in == nil { 127 | return nil 128 | } 129 | out := new(OvpnClientSpec) 130 | in.DeepCopyInto(out) 131 | return out 132 | } 133 | 134 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 135 | func (in *OvpnClientStatus) DeepCopyInto(out *OvpnClientStatus) { 136 | *out = *in 137 | } 138 | 139 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnClientStatus. 140 | func (in *OvpnClientStatus) DeepCopy() *OvpnClientStatus { 141 | if in == nil { 142 | return nil 143 | } 144 | out := new(OvpnClientStatus) 145 | in.DeepCopyInto(out) 146 | return out 147 | } 148 | 149 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 150 | func (in *OvpnPKICertificateConfig) DeepCopyInto(out *OvpnPKICertificateConfig) { 151 | *out = *in 152 | out.OvpnCertificateConfig = in.OvpnCertificateConfig 153 | } 154 | 155 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnPKICertificateConfig. 156 | func (in *OvpnPKICertificateConfig) DeepCopy() *OvpnPKICertificateConfig { 157 | if in == nil { 158 | return nil 159 | } 160 | out := new(OvpnPKICertificateConfig) 161 | in.DeepCopyInto(out) 162 | return out 163 | } 164 | 165 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 166 | func (in *OvpnPkiConfig) DeepCopyInto(out *OvpnPkiConfig) { 167 | *out = *in 168 | out.OvpnPKICertificateConfig = in.OvpnPKICertificateConfig 169 | out.DN = in.DN 170 | } 171 | 172 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnPkiConfig. 173 | func (in *OvpnPkiConfig) DeepCopy() *OvpnPkiConfig { 174 | if in == nil { 175 | return nil 176 | } 177 | out := new(OvpnPkiConfig) 178 | in.DeepCopyInto(out) 179 | return out 180 | } 181 | 182 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 183 | func (in *OvpnPkiDnConfig) DeepCopyInto(out *OvpnPkiDnConfig) { 184 | *out = *in 185 | } 186 | 187 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnPkiDnConfig. 188 | func (in *OvpnPkiDnConfig) DeepCopy() *OvpnPkiDnConfig { 189 | if in == nil { 190 | return nil 191 | } 192 | out := new(OvpnPkiDnConfig) 193 | in.DeepCopyInto(out) 194 | return out 195 | } 196 | 197 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 198 | func (in *OvpnSecurityConfig) DeepCopyInto(out *OvpnSecurityConfig) { 199 | *out = *in 200 | out.PKI = in.PKI 201 | out.Server = in.Server 202 | out.Clients = in.Clients 203 | } 204 | 205 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnSecurityConfig. 206 | func (in *OvpnSecurityConfig) DeepCopy() *OvpnSecurityConfig { 207 | if in == nil { 208 | return nil 209 | } 210 | out := new(OvpnSecurityConfig) 211 | in.DeepCopyInto(out) 212 | return out 213 | } 214 | 215 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 216 | func (in *OvpnServer) DeepCopyInto(out *OvpnServer) { 217 | *out = *in 218 | out.TypeMeta = in.TypeMeta 219 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 220 | in.Spec.DeepCopyInto(&out.Spec) 221 | out.Status = in.Status 222 | } 223 | 224 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnServer. 225 | func (in *OvpnServer) DeepCopy() *OvpnServer { 226 | if in == nil { 227 | return nil 228 | } 229 | out := new(OvpnServer) 230 | in.DeepCopyInto(out) 231 | return out 232 | } 233 | 234 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 235 | func (in *OvpnServer) DeepCopyObject() runtime.Object { 236 | if c := in.DeepCopy(); c != nil { 237 | return c 238 | } 239 | return nil 240 | } 241 | 242 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 243 | func (in *OvpnServerAddress) DeepCopyInto(out *OvpnServerAddress) { 244 | *out = *in 245 | } 246 | 247 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnServerAddress. 248 | func (in *OvpnServerAddress) DeepCopy() *OvpnServerAddress { 249 | if in == nil { 250 | return nil 251 | } 252 | out := new(OvpnServerAddress) 253 | in.DeepCopyInto(out) 254 | return out 255 | } 256 | 257 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 258 | func (in *OvpnServerCertificateConfig) DeepCopyInto(out *OvpnServerCertificateConfig) { 259 | *out = *in 260 | out.OvpnCertificateConfig = in.OvpnCertificateConfig 261 | } 262 | 263 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnServerCertificateConfig. 264 | func (in *OvpnServerCertificateConfig) DeepCopy() *OvpnServerCertificateConfig { 265 | if in == nil { 266 | return nil 267 | } 268 | out := new(OvpnServerCertificateConfig) 269 | in.DeepCopyInto(out) 270 | return out 271 | } 272 | 273 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 274 | func (in *OvpnServerDeployment) DeepCopyInto(out *OvpnServerDeployment) { 275 | *out = *in 276 | if in.Annotations != nil { 277 | in, out := &in.Annotations, &out.Annotations 278 | *out = make(map[string]string, len(*in)) 279 | for key, val := range *in { 280 | (*out)[key] = val 281 | } 282 | } 283 | if in.PodAnnotations != nil { 284 | in, out := &in.PodAnnotations, &out.PodAnnotations 285 | *out = make(map[string]string, len(*in)) 286 | for key, val := range *in { 287 | (*out)[key] = val 288 | } 289 | } 290 | } 291 | 292 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnServerDeployment. 293 | func (in *OvpnServerDeployment) DeepCopy() *OvpnServerDeployment { 294 | if in == nil { 295 | return nil 296 | } 297 | out := new(OvpnServerDeployment) 298 | in.DeepCopyInto(out) 299 | return out 300 | } 301 | 302 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 303 | func (in *OvpnServerList) DeepCopyInto(out *OvpnServerList) { 304 | *out = *in 305 | out.TypeMeta = in.TypeMeta 306 | in.ListMeta.DeepCopyInto(&out.ListMeta) 307 | if in.Items != nil { 308 | in, out := &in.Items, &out.Items 309 | *out = make([]OvpnServer, len(*in)) 310 | for i := range *in { 311 | (*in)[i].DeepCopyInto(&(*out)[i]) 312 | } 313 | } 314 | } 315 | 316 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnServerList. 317 | func (in *OvpnServerList) DeepCopy() *OvpnServerList { 318 | if in == nil { 319 | return nil 320 | } 321 | out := new(OvpnServerList) 322 | in.DeepCopyInto(out) 323 | return out 324 | } 325 | 326 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 327 | func (in *OvpnServerList) DeepCopyObject() runtime.Object { 328 | if c := in.DeepCopy(); c != nil { 329 | return c 330 | } 331 | return nil 332 | } 333 | 334 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 335 | func (in *OvpnServerSecrets) DeepCopyInto(out *OvpnServerSecrets) { 336 | *out = *in 337 | } 338 | 339 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnServerSecrets. 340 | func (in *OvpnServerSecrets) DeepCopy() *OvpnServerSecrets { 341 | if in == nil { 342 | return nil 343 | } 344 | out := new(OvpnServerSecrets) 345 | in.DeepCopyInto(out) 346 | return out 347 | } 348 | 349 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 350 | func (in *OvpnServerService) DeepCopyInto(out *OvpnServerService) { 351 | *out = *in 352 | if in.Annotations != nil { 353 | in, out := &in.Annotations, &out.Annotations 354 | *out = make(map[string]string, len(*in)) 355 | for key, val := range *in { 356 | (*out)[key] = val 357 | } 358 | } 359 | } 360 | 361 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnServerService. 362 | func (in *OvpnServerService) DeepCopy() *OvpnServerService { 363 | if in == nil { 364 | return nil 365 | } 366 | out := new(OvpnServerService) 367 | in.DeepCopyInto(out) 368 | return out 369 | } 370 | 371 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 372 | func (in *OvpnServerSpec) DeepCopyInto(out *OvpnServerSpec) { 373 | *out = *in 374 | out.Network = in.Network 375 | in.Traffic.DeepCopyInto(&out.Traffic) 376 | out.Security = in.Security 377 | out.Secrets = in.Secrets 378 | in.Deployment.DeepCopyInto(&out.Deployment) 379 | in.Service.DeepCopyInto(&out.Service) 380 | } 381 | 382 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnServerSpec. 383 | func (in *OvpnServerSpec) DeepCopy() *OvpnServerSpec { 384 | if in == nil { 385 | return nil 386 | } 387 | out := new(OvpnServerSpec) 388 | in.DeepCopyInto(out) 389 | return out 390 | } 391 | 392 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 393 | func (in *OvpnServerStatus) DeepCopyInto(out *OvpnServerStatus) { 394 | *out = *in 395 | } 396 | 397 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnServerStatus. 398 | func (in *OvpnServerStatus) DeepCopy() *OvpnServerStatus { 399 | if in == nil { 400 | return nil 401 | } 402 | out := new(OvpnServerStatus) 403 | in.DeepCopyInto(out) 404 | return out 405 | } 406 | 407 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 408 | func (in *OvpnTrafficConfig) DeepCopyInto(out *OvpnTrafficConfig) { 409 | *out = *in 410 | if in.Routes != nil { 411 | in, out := &in.Routes, &out.Routes 412 | *out = make([]SubnetMask, len(*in)) 413 | copy(*out, *in) 414 | } 415 | if in.Nameservers != nil { 416 | in, out := &in.Nameservers, &out.Nameservers 417 | *out = make([]IPv4Address, len(*in)) 418 | copy(*out, *in) 419 | } 420 | } 421 | 422 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OvpnTrafficConfig. 423 | func (in *OvpnTrafficConfig) DeepCopy() *OvpnTrafficConfig { 424 | if in == nil { 425 | return nil 426 | } 427 | out := new(OvpnTrafficConfig) 428 | in.DeepCopyInto(out) 429 | return out 430 | } 431 | -------------------------------------------------------------------------------- /pkg/controllers/ovpnclient.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | api "github.com/borchero/meerkat-operator/pkg/api/v1alpha1" 9 | "github.com/borchero/meerkat-operator/pkg/crypto" 10 | "github.com/borchero/meerkat-operator/pkg/ovpn" 11 | vaultapi "github.com/hashicorp/vault/api" 12 | "go.uber.org/zap" 13 | corev1 "k8s.io/api/core/v1" 14 | apierrors "k8s.io/apimachinery/pkg/api/errors" 15 | "k8s.io/apimachinery/pkg/runtime" 16 | ctrl "sigs.k8s.io/controller-runtime" 17 | ctclient "sigs.k8s.io/controller-runtime/pkg/client" 18 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 19 | ) 20 | 21 | // +kubebuilder:rbac:groups=meerkat.borchero.com,resources=ovpnclients,verbs=get;list;watch;create;update;patch;delete 22 | 23 | // OvpnClientReconciler reconciles OvpnClient objects. 24 | type OvpnClientReconciler struct { 25 | ctclient.Client 26 | config Config 27 | vault *vaultapi.Client 28 | scheme *runtime.Scheme 29 | logger *zap.Logger 30 | } 31 | 32 | // MustSetupOvpnClientReconciler initializes a new server reconciler and attaches it to the given 33 | // manager. It panics on failure. 34 | func MustSetupOvpnClientReconciler( 35 | config Config, vault *vaultapi.Client, mgr ctrl.Manager, logger *zap.Logger, 36 | ) { 37 | reconciler := &OvpnClientReconciler{ 38 | Client: mgr.GetClient(), 39 | config: config, 40 | vault: vault, 41 | scheme: mgr.GetScheme(), 42 | logger: logger, 43 | } 44 | if err := reconciler.setupWithManager(mgr); err != nil { 45 | panic(err) 46 | } 47 | } 48 | 49 | func (r *OvpnClientReconciler) setupWithManager(mgr ctrl.Manager) error { 50 | return ctrl.NewControllerManagedBy(mgr). 51 | For(&api.OvpnClient{}). 52 | Owns(&corev1.Secret{}). 53 | Complete(r) 54 | } 55 | 56 | //------------------------------------------------------------------------------------------------- 57 | 58 | const ( 59 | secretKeyOvpnCertificate = "certificate.ovpn" 60 | 61 | annotationKeySerial = "meerkat.borchero.com/serial" 62 | annotationKeyDirty = "meerkat.borchero.com/dirty" 63 | ) 64 | 65 | // Reconcile reconciles the given request. 66 | func (r *OvpnClientReconciler) Reconcile( 67 | ctx context.Context, req ctrl.Request, 68 | ) (ctrl.Result, error) { 69 | logger := r.logger.With(zap.String("name", req.String())) 70 | logger.Debug("starting reconciliation") 71 | 72 | // First, we get the client 73 | client := &api.OvpnClient{} 74 | err := r.Get(ctx, req.NamespacedName, client) 75 | if err != nil { 76 | return ctrl.Result{}, ctclient.IgnoreNotFound(err) 77 | } 78 | 79 | // If the client currently exists, we need to check for deletion 80 | if !client.DeletionTimestamp.IsZero() { 81 | // In that case, we need to revoke the certificate if the finalizer still exists 82 | if controllerutil.ContainsFinalizer(client, finalizerIdentifier) { 83 | if err := r.revokeCertificate(ctx, client, logger); err != nil { 84 | logger.Error("failed to revoke certificate", zap.Error(err)) 85 | return ctrl.Result{}, err 86 | } 87 | logger.Debug("successfully revoked certificate") 88 | } 89 | controllerutil.RemoveFinalizer(client, finalizerIdentifier) 90 | if err := r.Update(ctx, client); err != nil { 91 | logger.Error("failed removing finalizer", zap.Error(err)) 92 | return ctrl.Result{}, err 93 | } 94 | // Returning here automatically removes the secret 95 | return ctrl.Result{}, nil 96 | } 97 | 98 | // If we create/update the client, we add the finalizer 99 | if !controllerutil.ContainsFinalizer(client, finalizerIdentifier) { 100 | controllerutil.AddFinalizer(client, finalizerIdentifier) 101 | if err := r.Update(ctx, client); err != nil { 102 | logger.Error("failed adding finalizer", zap.Error(err)) 103 | return ctrl.Result{}, err 104 | } 105 | } 106 | 107 | // Then, we can create the client's certificate 108 | if err := r.updateCertificate(ctx, client); err != nil { 109 | logger.Error("failed to reconcile certificate", zap.Error(err)) 110 | return ctrl.Result{}, err 111 | } 112 | 113 | logger.Info("reconciliation succeeded") 114 | return ctrl.Result{}, nil 115 | } 116 | 117 | //------------------------------------------------------------------------------------------------- 118 | 119 | func (r *OvpnClientReconciler) revokeCertificate( 120 | ctx context.Context, client *api.OvpnClient, logger *zap.Logger, 121 | ) error { 122 | // First, we need to get the certificate secret 123 | secret := &corev1.Secret{ObjectMeta: client.ObjectRefCertificateSecret()} 124 | err := r.Get(ctx, ctclient.ObjectKeyFromObject(secret), secret) 125 | if err != nil && !apierrors.IsNotFound(err) { 126 | return fmt.Errorf("failed to check for certificate secret: %s", err) 127 | } 128 | 129 | if apierrors.IsNotFound(err) { 130 | // If the certificate secret does not exist, we can simply return 131 | return nil 132 | } 133 | 134 | // If the secret does exist, we parse the expiration date 135 | expirationString, ok := secret.Annotations[annotationKeyExpiresAt] 136 | if !ok { 137 | // We can't know if the secret is expired, we don't do anything 138 | return nil 139 | } 140 | expiration, err := time.Parse(time.RFC3339, expirationString) 141 | if err != nil { 142 | // We don't know about expiration again 143 | logger.Warn("revocation skipped due to missing expiration date") 144 | return nil 145 | } 146 | if expiration.Before(time.Now()) { 147 | // If the expiration is in the past, we can return 148 | logger.Warn("revocation skipped due to invalid expiration date") 149 | return nil 150 | } 151 | 152 | // If the certificate has not expired yet, we need to revoke it. For that, it is required that 153 | // we have a serial. 154 | serial, ok := secret.Annotations[annotationKeySerial] 155 | if !ok { 156 | // We will never be able to revoke the certificate, so we just return 157 | logger.Warn("revocation skipped due to missing serial") 158 | return nil 159 | } 160 | 161 | // Then, we fetch the associated server to get the correct PKI 162 | server := &api.OvpnServer{} 163 | serverRef := ctclient.ObjectKey{Name: client.Spec.ServerName, Namespace: client.Namespace} 164 | if err := r.Get(ctx, serverRef, server); err != nil { 165 | if apierrors.IsNotFound(err) { 166 | // If the server cannot be found, we don't need to revoke anything so we're done 167 | return nil 168 | } 169 | return fmt.Errorf("failed to get server associated with client: %s", err) 170 | } 171 | 172 | // Then, we get the PKI and revoke the certificate with the serial from above 173 | pki := r.getPKI(server) 174 | if err := pki.Revoke(serial); err != nil { 175 | return fmt.Errorf("failed to revoke certificate: %s", err) 176 | } 177 | 178 | // After doing so, we need to trigger an update of the CRL. We simply trigger a reconciliation 179 | // of the server by adding an annotation to the secret. 180 | crl := &corev1.Secret{ObjectMeta: server.ObjectRefCrlSecret()} 181 | op, err := ctrl.CreateOrUpdate(ctx, r, crl, func() error { 182 | secret.Annotations = map[string]string{ 183 | annotationKeyDirty: "true", 184 | } 185 | return nil 186 | }) 187 | if err != nil { 188 | return fmt.Errorf("failed to flag CRL secret as dirty: %s", err) 189 | } 190 | logger.Debug("flagged CRL as dirty", zap.String("operation", string(op))) 191 | return nil 192 | } 193 | 194 | //------------------------------------------------------------------------------------------------- 195 | 196 | func (r *OvpnClientReconciler) updateCertificate( 197 | ctx context.Context, client *api.OvpnClient, 198 | ) error { 199 | // First, we get the certificate secret 200 | secret := &corev1.Secret{ObjectMeta: client.ObjectRefCertificateSecret()} 201 | err := r.Get(ctx, ctclient.ObjectKeyFromObject(secret), secret) 202 | if err != nil && !apierrors.IsNotFound(err) { 203 | return fmt.Errorf("failed to check for certificate secret: %s", err) 204 | } 205 | if err == nil { 206 | // If the certificate already exists, we don't do anything. Specifially, we don't 207 | // automatically renew certificates. 208 | return nil 209 | } 210 | 211 | // If we cannot find it, we create the certificate. For that, we first need to find the server 212 | // which is responsible for the user. 213 | server := &api.OvpnServer{} 214 | serverRef := ctclient.ObjectKey{Name: client.Spec.ServerName, Namespace: client.Namespace} 215 | if err := r.Get(ctx, serverRef, server); err != nil { 216 | return fmt.Errorf("failed to get server associated with client: %s", err) 217 | } 218 | 219 | // Then, we can get the correct PKI. 220 | pki := r.getPKI(server) 221 | 222 | // Afterwards, we can generate the private key and certificate. 223 | validity := client.Spec.Certificate.Validity.Duration 224 | if validity == 0 { 225 | validity = server.Spec.Security.Clients.DefaultedValidity() 226 | } 227 | certificate, err := pki.Generate("client", client.Spec.CommonName, validity) 228 | if err != nil { 229 | return fmt.Errorf("failed to generate new certificate: %s", err) 230 | } 231 | 232 | // With the certificate, we can now load the shared TLSAuth parameter and then write the 233 | // full OVPN certificate. 234 | sharedSecret := &corev1.Secret{ObjectMeta: server.ObjectRefSharedSecrets()} 235 | if err := r.Get(ctx, ctclient.ObjectKeyFromObject(sharedSecret), sharedSecret); err != nil { 236 | return fmt.Errorf("failed to get shared secret to build OVPN certificate: %s", err) 237 | } 238 | 239 | // Get the TLS auth parameters 240 | tlsAuth, ok := sharedSecret.Data[secretKeyTa] 241 | if !ok { 242 | return fmt.Errorf("shared secret does not contain TLS auth") 243 | } 244 | 245 | // Render the file 246 | values := ovpn.CertificateValues{ 247 | Host: server.Spec.Network.Host, 248 | Port: server.Spec.Service.DefaultedPort(), 249 | Protocol: string(server.Spec.Network.DefaultedProtocol()), 250 | Security: ovpn.ConfigSecurity{ 251 | Hmac: string(server.Spec.Security.DefaultedHmac()), 252 | Cipher: string(server.Spec.Security.DefaultedCipher()), 253 | }, 254 | Secrets: ovpn.CertificateSecrets{ 255 | TLSClientKey: certificate.PrivateKey, 256 | TLSClientCrt: certificate.Certificate, 257 | TLSCaCrt: certificate.CACertificate, 258 | TLSAuth: string(tlsAuth), 259 | }, 260 | } 261 | ovpnCert, err := ovpn.GetCertificate(values) 262 | if err != nil { 263 | return fmt.Errorf("failed to render OVPN certificate: %s", err) 264 | } 265 | 266 | // And finally, we can store the certificate in the previously referenced secret 267 | secret.Annotations = map[string]string{ 268 | annotationKeyExpiresAt: certificate.Expiration.Format(time.RFC3339), 269 | annotationKeySerial: certificate.Serial, 270 | } 271 | secret.StringData = map[string]string{ 272 | secretKeyOvpnCertificate: ovpnCert, 273 | } 274 | if err := ctrl.SetControllerReference(client, secret, r.scheme); err != nil { 275 | return fmt.Errorf("failed to set owner reference on certificate secret: %s", err) 276 | } 277 | if err := r.Create(ctx, secret); err != nil { 278 | return fmt.Errorf("failed to create secret containing certificate: %s", err) 279 | } 280 | return nil 281 | } 282 | 283 | //------------------------------------------------------------------------------------------------- 284 | 285 | func (r *OvpnClientReconciler) getPKI(server *api.OvpnServer) *crypto.PKI { 286 | return crypto.NewPKI( 287 | r.vault, fmt.Sprintf("%s/%s/%s", r.config.PKIPath, server.Namespace, server.Name), 288 | ) 289 | } 290 | -------------------------------------------------------------------------------- /pkg/controllers/ovpnserver.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "time" 8 | 9 | api "github.com/borchero/meerkat-operator/pkg/api/v1alpha1" 10 | "github.com/borchero/meerkat-operator/pkg/controllers/ovpnserver" 11 | "github.com/borchero/meerkat-operator/pkg/crypto" 12 | "github.com/borchero/meerkat-operator/pkg/ovpn" 13 | vaultapi "github.com/hashicorp/vault/api" 14 | "go.uber.org/zap" 15 | appsv1 "k8s.io/api/apps/v1" 16 | corev1 "k8s.io/api/core/v1" 17 | "k8s.io/apimachinery/pkg/api/equality" 18 | apierrors "k8s.io/apimachinery/pkg/api/errors" 19 | "k8s.io/apimachinery/pkg/runtime" 20 | ctrl "sigs.k8s.io/controller-runtime" 21 | "sigs.k8s.io/controller-runtime/pkg/client" 22 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 23 | ) 24 | 25 | // +kubebuilder:rbac:groups=meerkat.borchero.com,resources=ovpnservers,verbs=get;list;watch;create;update;patch;delete 26 | // +kubebuilder:rbac:groups=meerkat.borchero.com,resources=ovpnservers/status,verbs=get;update;patch 27 | // +kubebuilder:rbac:groups=core,resources=secrets;configmaps;services,verbs=get;list;watch;create;update;patch;delete 28 | // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete 29 | 30 | // OvpnServerReconciler reconciles OvpnServer objects. 31 | type OvpnServerReconciler struct { 32 | client.Client 33 | config Config 34 | vault *vaultapi.Client 35 | scheme *runtime.Scheme 36 | logger *zap.Logger 37 | } 38 | 39 | // MustSetupOvpnServerReconciler initializes a new server reconciler and attaches it to the given 40 | // manager. It panics on failure. 41 | func MustSetupOvpnServerReconciler( 42 | config Config, vault *vaultapi.Client, mgr ctrl.Manager, logger *zap.Logger, 43 | ) { 44 | reconciler := &OvpnServerReconciler{ 45 | Client: mgr.GetClient(), 46 | config: config, 47 | vault: vault, 48 | scheme: mgr.GetScheme(), 49 | logger: logger, 50 | } 51 | if err := reconciler.setupWithManager(mgr); err != nil { 52 | panic(err) 53 | } 54 | } 55 | 56 | func (r *OvpnServerReconciler) setupWithManager(mgr ctrl.Manager) error { 57 | return ctrl.NewControllerManagedBy(mgr). 58 | For(&api.OvpnServer{}). 59 | Owns(&corev1.Secret{}). 60 | Owns(&corev1.ConfigMap{}). 61 | Owns(&appsv1.Deployment{}). 62 | Owns(&corev1.Service{}). 63 | Complete(r) 64 | } 65 | 66 | //------------------------------------------------------------------------------------------------- 67 | 68 | const ( 69 | secretKeyDh = "dh.pem" 70 | secretKeyTa = "ta.key" 71 | secretKeyCrl = "crl.pem" 72 | secretKeyServerCrt = "server.crt" 73 | secretKeyServerKey = "server.key" 74 | secretKeyCaCrt = "ca.crt" 75 | secretKeySerial = "serial" 76 | configMapKeyEntrypoint = "entrypoint.sh" 77 | configMapKeyOvpnConfig = "openvpn.conf" 78 | 79 | annotationKeyExpiresAt = "meerkat.borchero.com/expires-at" 80 | 81 | finalizerIdentifier = "finalizers.meerkat.borchero.com" 82 | ) 83 | 84 | // Reconcile reconciles the given request. 85 | func (r *OvpnServerReconciler) Reconcile( 86 | ctx context.Context, req ctrl.Request, 87 | ) (ctrl.Result, error) { 88 | logger := r.logger.With(zap.String("name", req.String())) 89 | logger.Info("starting reconciliation") 90 | 91 | // First, we get the server - if it cannot be found, we return no error 92 | server := &api.OvpnServer{} 93 | err := r.Get(ctx, req.NamespacedName, server) 94 | if err != nil { 95 | return ctrl.Result{}, client.IgnoreNotFound(err) 96 | } 97 | 98 | // The server currently exists, so we check if we need to delete it. 99 | if !server.DeletionTimestamp.IsZero() { 100 | // In that case, we need to make sure that we delete the PKI associated with the server 101 | if controllerutil.ContainsFinalizer(server, finalizerIdentifier) { 102 | if err := r.deletePKI(ctx, server); err != nil { 103 | logger.Error("failed purging PKI", zap.Error(err)) 104 | return ctrl.Result{}, err 105 | } 106 | controllerutil.RemoveFinalizer(server, finalizerIdentifier) 107 | if err := r.Update(ctx, server); err != nil { 108 | logger.Error("failed removing finalizer", zap.Error(err)) 109 | return ctrl.Result{}, err 110 | } 111 | } 112 | // Returning here tells Kubernetes that the server and its dependents can be removed 113 | return ctrl.Result{}, nil 114 | } 115 | logger.Debug("server has not been deleted") 116 | 117 | // Prior to anything, we add our finalizer if required 118 | logger.Debug("reconciling finalizers") 119 | if !controllerutil.ContainsFinalizer(server, finalizerIdentifier) { 120 | controllerutil.AddFinalizer(server, finalizerIdentifier) 121 | if err := r.Update(ctx, server); err != nil { 122 | logger.Error("failed adding finalizer", zap.Error(err)) 123 | return ctrl.Result{}, err 124 | } 125 | } 126 | 127 | // Otherwise, the server is not being deleted, so we can reconcile. First, we want to ensure 128 | // that the shared secrets exist. 129 | logger.Debug("reconciling shared secrets") 130 | if err := r.updateSharedSecret(ctx, server, logger); err != nil { 131 | logger.Error("failed to reconcile shared secrets", zap.Error(err)) 132 | return ctrl.Result{}, err 133 | } 134 | 135 | // Afterwards, we make sure that the PKI is established correctly. 136 | logger.Debug("reconciling PKI") 137 | if err := r.updatePKI(ctx, server, logger); err != nil { 138 | logger.Error("failed to reconcile PKI", zap.Error(err)) 139 | return ctrl.Result{}, err 140 | } 141 | 142 | // As soon as that succeeded, we can create a certificate for the server to use. We use the 143 | // `expiresAt` value to set an annotation on the deployment pods to reload them as soon as 144 | // a new certificate has been generated. 145 | logger.Debug("reconciling server certificate") 146 | expiresAt, err := r.updateServerCertificate(ctx, server, logger) 147 | if err != nil { 148 | logger.Error("failed to reconcile server certificate", zap.Error(err)) 149 | return ctrl.Result{}, err 150 | } 151 | 152 | // We can then update the configuration, entrypoint, deployment, and service 153 | logger.Debug("reconciling k8s resources") 154 | if err := r.updateConfigMaps(ctx, server, logger); err != nil { 155 | logger.Error("failed to reconcile configmaps", zap.Error(err)) 156 | return ctrl.Result{}, err 157 | } 158 | if err := r.updateDeployment(ctx, server, expiresAt, logger); err != nil { 159 | logger.Error("failed to reconcile deployment", zap.Error(err)) 160 | return ctrl.Result{}, err 161 | } 162 | if err := r.updateService(ctx, server, logger); err != nil { 163 | logger.Error("failed to reconcile service", zap.Error(err)) 164 | return ctrl.Result{}, err 165 | } 166 | 167 | logger.Info("reconciliation succeeded") 168 | return ctrl.Result{}, nil 169 | } 170 | 171 | //------------------------------------------------------------------------------------------------- 172 | 173 | func (r *OvpnServerReconciler) deletePKI(ctx context.Context, server *api.OvpnServer) error { 174 | pki := r.getPKI(server) 175 | return pki.DisableIfEnabled() 176 | } 177 | 178 | //------------------------------------------------------------------------------------------------- 179 | 180 | func (r *OvpnServerReconciler) updateSharedSecret( 181 | ctx context.Context, server *api.OvpnServer, logger *zap.Logger, 182 | ) error { 183 | secret := &corev1.Secret{ObjectMeta: server.ObjectRefSharedSecrets()} 184 | 185 | // If the secret already exists and the keys exist, we don't have to do anything 186 | err := r.Get(ctx, client.ObjectKeyFromObject(secret), secret) 187 | if err != nil && !apierrors.IsNotFound(err) { 188 | return fmt.Errorf("failed to check for shared secret: %s", err) 189 | } 190 | if !apierrors.IsNotFound(err) { 191 | _, dhExists := secret.Data[secretKeyDh] 192 | _, taExists := secret.Data[secretKeyTa] 193 | if dhExists && taExists { 194 | return nil 195 | } 196 | } 197 | 198 | // Otherwise, we need to generate them 199 | logger.Info("generating DH parameters, this will take a long time") 200 | bits := server.Spec.Security.DiffieHellmanBits 201 | if bits == 0 { 202 | bits = 2048 203 | } 204 | dh, err := crypto.GenerateDhParams(bits) 205 | if err != nil { 206 | return fmt.Errorf("failed to generate DH params: %s", err) 207 | } 208 | ta, err := crypto.GenerateTLSAuth() 209 | if err != nil { 210 | return fmt.Errorf("failed to generate TLS auth: %s", err) 211 | } 212 | 213 | data := map[string][]byte{secretKeyDh: dh, secretKeyTa: ta} 214 | op, err := ctrl.CreateOrUpdate(ctx, r, secret, func() error { 215 | secret.Data = data 216 | return ctrl.SetControllerReference(server, secret, r.scheme) 217 | }) 218 | if err != nil { 219 | return fmt.Errorf("failed to upsert shared secret: %s", err) 220 | } 221 | logger.Debug("reconciled shared secret", zap.String("operation", string(op))) 222 | return nil 223 | } 224 | 225 | func (r *OvpnServerReconciler) updatePKI( 226 | ctx context.Context, server *api.OvpnServer, logger *zap.Logger, 227 | ) error { 228 | pki := r.getPKI(server) 229 | 230 | // First, we make sure that everything is configured correctly 231 | if err := pki.EnsureEnabled(); err != nil { 232 | return fmt.Errorf("failed to ensure that PKI engine is enabled: %s", err) 233 | } 234 | if err := pki.GenerateRootIfRequired(ovpnserver.PKIConfig(server)); err != nil { 235 | return fmt.Errorf("failed to ensure root certificate: %s", err) 236 | } 237 | if err := pki.ConfigureRole("server", ovpnserver.PKIServerConfig(server)); err != nil { 238 | return fmt.Errorf("failed to ensure server configuration: %s", err) 239 | } 240 | if err := pki.ConfigureRole("client", ovpnserver.PKIClientConfig(server)); err != nil { 241 | return fmt.Errorf("failed to ensure client configuration: %s", err) 242 | } 243 | 244 | // Then, we pull the CRL into the respective secret 245 | crl, err := pki.GetCRL() 246 | if err != nil { 247 | return fmt.Errorf("failed to fetch up-to-date CRL: %s", err) 248 | } 249 | 250 | secret := &corev1.Secret{ObjectMeta: server.ObjectRefCrlSecret()} 251 | op, err := ctrl.CreateOrUpdate(ctx, r, secret, func() error { 252 | secret.Annotations = map[string]string{} 253 | secret.Data = map[string][]byte{ 254 | secretKeyCrl: []byte(crl.Certificate), 255 | } 256 | return ctrl.SetControllerReference(server, secret, r.scheme) 257 | }) 258 | if err != nil { 259 | return fmt.Errorf("failed to update CRL secret: %s", err) 260 | } 261 | logger.Debug("updated CRL", zap.String("operation", string(op))) 262 | return nil 263 | } 264 | 265 | func (r *OvpnServerReconciler) updateServerCertificate( 266 | ctx context.Context, server *api.OvpnServer, logger *zap.Logger, 267 | ) (string, error) { 268 | // First, we get the certificate 269 | secret := &corev1.Secret{ObjectMeta: server.ObjectRefServerCertificateSecret()} 270 | if err := r.Get(ctx, client.ObjectKeyFromObject(secret), secret); err != nil { 271 | if !apierrors.IsNotFound(err) { 272 | return "", fmt.Errorf("failed to get existing secret: %s", err) 273 | } 274 | } 275 | 276 | // If it exists, we parse the expiration date and check if it is far in the future (more than 277 | // one sixth of its validity). If so, we return without error 278 | if expiresAt, ok := secret.Annotations[annotationKeyExpiresAt]; ok { 279 | deadline, err := time.Parse(time.RFC3339, expiresAt) 280 | if err == nil { 281 | remaining := deadline.Sub(time.Now()) 282 | if remaining > server.Spec.Security.Server.DefaultedValidity()/6 { 283 | return expiresAt, nil 284 | } 285 | } 286 | } 287 | 288 | // Otherwise, we issue a new certificate... 289 | pki := r.getPKI(server) 290 | cert, err := pki.Generate( 291 | "server", server.Spec.Network.Host, server.Spec.Security.Server.DefaultedValidity(), 292 | ) 293 | if err != nil { 294 | return "", fmt.Errorf("failed to generate new certificate: %s", err) 295 | } 296 | 297 | // ... and update the secret accordingly 298 | expiresAt := cert.Expiration.Format(time.RFC3339) 299 | op, err := ctrl.CreateOrUpdate(ctx, r, secret, func() error { 300 | secret.Annotations = map[string]string{ 301 | annotationKeyExpiresAt: expiresAt, 302 | } 303 | secret.Data = map[string][]byte{ 304 | secretKeyServerCrt: []byte(cert.Certificate), 305 | secretKeyServerKey: []byte(cert.PrivateKey), 306 | secretKeyCaCrt: []byte(cert.CACertificate), 307 | } 308 | return ctrl.SetControllerReference(server, secret, r.scheme) 309 | }) 310 | if err != nil { 311 | return "", fmt.Errorf("failed to upsert server certificate secret: %s", err) 312 | } 313 | logger.Debug("updated server certificate", zap.String("operation", string(op))) 314 | return expiresAt, nil 315 | } 316 | 317 | func (r *OvpnServerReconciler) updateConfigMaps( 318 | ctx context.Context, server *api.OvpnServer, logger *zap.Logger, 319 | ) error { 320 | // First, let's update the entrypoint 321 | cm := &corev1.ConfigMap{ObjectMeta: server.ObjectRefEntrypointConfigMap()} 322 | entrypointValues := ovpn.EntrypointValues{ 323 | Routes: ovpn.ParseRoutesString(server.Spec.Traffic.Routes), 324 | } 325 | data, err := ovpn.GetEntrypoint(entrypointValues) 326 | if err != nil { 327 | return fmt.Errorf("failed to get code for entrypoint: %s", err) 328 | } 329 | 330 | op, err := ctrl.CreateOrUpdate(ctx, r, cm, func() error { 331 | cm.Data = map[string]string{configMapKeyEntrypoint: data} 332 | return ctrl.SetControllerReference(server, cm, r.scheme) 333 | }) 334 | if err != nil { 335 | return fmt.Errorf("failed to upsert server entrypoint: %s", err) 336 | } 337 | logger.Debug("updated entrypoint", zap.String("operation", string(op))) 338 | 339 | // Then, update the configuration 340 | cm = &corev1.ConfigMap{ObjectMeta: server.ObjectRefOvpnConfigMap()} 341 | configValues := ovpn.ConfigValues{ 342 | Nameservers: server.Spec.Traffic.DefaultedNameservers(), 343 | RedirectAll: server.Spec.Traffic.RedirectAll, 344 | Protocol: string(server.Spec.Network.Protocol), 345 | Routes: ovpn.ParseRoutes(server.Spec.Traffic.Routes), 346 | Security: ovpn.ConfigSecurity{ 347 | Hmac: string(server.Spec.Security.DefaultedHmac()), 348 | Cipher: string(server.Spec.Security.DefaultedCipher()), 349 | }, 350 | Files: ovpn.ConfigFiles{ 351 | TLSServerCrt: filepath.Join(ovpnserver.MountPathTLSKeys, secretKeyServerCrt), 352 | TLSServerKey: filepath.Join(ovpnserver.MountPathTLSKeys, secretKeyServerKey), 353 | TLSCaCrt: filepath.Join(ovpnserver.MountPathTLSKeys, secretKeyCaCrt), 354 | DHParams: filepath.Join(ovpnserver.MountPathSharedSecrets, secretKeyDh), 355 | TLSAuth: filepath.Join(ovpnserver.MountPathSharedSecrets, secretKeyTa), 356 | CRL: filepath.Join(ovpnserver.MountPathCrl, secretKeyCrl), 357 | }, 358 | } 359 | data, err = ovpn.GetConfig(configValues) 360 | if err != nil { 361 | return fmt.Errorf("failed to get OVPN config: %s", err) 362 | } 363 | 364 | op, err = ctrl.CreateOrUpdate(ctx, r, cm, func() error { 365 | cm.Data = map[string]string{configMapKeyOvpnConfig: data} 366 | return ctrl.SetControllerReference(server, cm, r.scheme) 367 | }) 368 | if err != nil { 369 | return fmt.Errorf("failed to upsert server config: %s", err) 370 | } 371 | logger.Debug("updated server config", zap.String("operation", string(op))) 372 | return nil 373 | } 374 | 375 | func (r *OvpnServerReconciler) updateDeployment( 376 | ctx context.Context, server *api.OvpnServer, certificateExpiration string, logger *zap.Logger, 377 | ) error { 378 | deployment := &appsv1.Deployment{ObjectMeta: server.ObjectRefDeployment()} 379 | expected := ovpnserver.GetDeploymentSpec(server, r.config.Image, map[string]string{ 380 | annotationKeyExpiresAt: certificateExpiration, 381 | }) 382 | 383 | op, err := controllerutil.CreateOrPatch(ctx, r, deployment, func() error { 384 | if server.Spec.Deployment.Annotations != nil { 385 | deployment.Annotations = server.Spec.Deployment.Annotations 386 | } 387 | deployment.Spec = expected 388 | return ctrl.SetControllerReference(server, deployment, r.scheme) 389 | }) 390 | if err != nil { 391 | return fmt.Errorf("failed to upsert deployment: %s", err) 392 | } 393 | logger.Debug("updated deployment", zap.String("operation", string(op))) 394 | return nil 395 | } 396 | 397 | func (r *OvpnServerReconciler) updateService( 398 | ctx context.Context, server *api.OvpnServer, logger *zap.Logger, 399 | ) error { 400 | service := &corev1.Service{ObjectMeta: server.ObjectRefService()} 401 | 402 | // First, we need to get the service 403 | err := r.Get(ctx, client.ObjectKeyFromObject(service), service) 404 | if err != nil && !apierrors.IsNotFound(err) { 405 | return fmt.Errorf("failed to fetch current service: %s", err) 406 | } 407 | expected := ovpnserver.GetServiceSpec(server) 408 | 409 | // If it exists, we need to check the type 410 | if !apierrors.IsNotFound(err) { 411 | if service.Spec.Type != server.Spec.Service.DefaultedServiceType() { 412 | // If the type is not equal, we need to delete the service 413 | if err := r.Delete(ctx, service); err != nil { 414 | return fmt.Errorf("failed to delete out-of-date service: %s", err) 415 | } 416 | logger.Debug("deleted old service") 417 | } else { 418 | // Otherwise, we potentially update if there are changes 419 | updated := false 420 | if !equality.Semantic.DeepEqual(service.Annotations, server.Spec.Service.Annotations) { 421 | service.Annotations = server.Spec.Service.Annotations 422 | updated = true 423 | } 424 | if !equality.Semantic.DeepEqual(service.Spec.Selector, expected.Selector) { 425 | service.Spec.Selector = expected.Selector 426 | updated = true 427 | } 428 | if expected.Type == corev1.ServiceTypeLoadBalancer && len(service.Spec.Ports) > 0 { 429 | // We need to make sure that the expected node port is not 0 430 | expected.Ports[0].NodePort = service.Spec.Ports[0].NodePort 431 | } 432 | if !equality.Semantic.DeepEqual(service.Spec.Ports, expected.Ports) { 433 | service.Spec.Ports = expected.Ports 434 | updated = true 435 | } 436 | if updated { 437 | if err := r.Update(ctx, service); err != nil { 438 | return fmt.Errorf("failed to update out-of-date service: %s", err) 439 | } 440 | logger.Debug("updated outdated service") 441 | } 442 | return nil 443 | } 444 | } 445 | 446 | // If it doesn't exist, we create it 447 | service.Annotations = server.Spec.Service.Annotations 448 | expected.DeepCopyInto(&service.Spec) 449 | if err := ctrl.SetControllerReference(server, service, r.scheme); err != nil { 450 | return fmt.Errorf("failed to set owner of service: %s", err) 451 | } 452 | if err := r.Create(ctx, service); err != nil { 453 | return fmt.Errorf("failed to create service: %s", err) 454 | } 455 | logger.Debug("created new service") 456 | return nil 457 | } 458 | 459 | //------------------------------------------------------------------------------------------------- 460 | 461 | func (r *OvpnServerReconciler) getPKI(server *api.OvpnServer) *crypto.PKI { 462 | return crypto.NewPKI( 463 | r.vault, fmt.Sprintf("%s/%s/%s", r.config.PKIPath, server.Namespace, server.Name), 464 | ) 465 | } 466 | -------------------------------------------------------------------------------- /pkg/controllers/ovpnserver/certificate.go: -------------------------------------------------------------------------------- 1 | package ovpnserver 2 | 3 | import ( 4 | api "github.com/borchero/meerkat-operator/pkg/api/v1alpha1" 5 | "github.com/borchero/meerkat-operator/pkg/crypto" 6 | ) 7 | 8 | // PKIConfig returns the PKI configuration for the PKI. 9 | func PKIConfig(server *api.OvpnServer) crypto.PKIConfig { 10 | return crypto.PKIConfig{ 11 | CommonName: server.Spec.Security.PKI.DN.DefaultedCommonName(), 12 | Validity: server.Spec.Security.PKI.DefaultedValidity(), 13 | RSABits: server.Spec.Security.PKI.DefaultedRSABits(), 14 | Organization: server.Spec.Security.PKI.DN.Organization, 15 | OrganizationalUnit: server.Spec.Security.PKI.DN.OrganizationalUnit, 16 | Country: server.Spec.Security.PKI.DN.Country, 17 | Locality: server.Spec.Security.PKI.DN.Locality, 18 | } 19 | } 20 | 21 | // PKIServerConfig returns the PKI configuration for the server component. 22 | func PKIServerConfig(server *api.OvpnServer) crypto.PKIRoleConfig { 23 | return crypto.PKIRoleConfig{ 24 | DefaultValidity: server.Spec.Security.Server.DefaultedValidity(), 25 | RSABits: server.Spec.Security.Server.DefaultedRSABits(), 26 | Server: true, 27 | } 28 | } 29 | 30 | // PKIClientConfig returns the PKI configuration for the client component. 31 | func PKIClientConfig(server *api.OvpnServer) crypto.PKIRoleConfig { 32 | return crypto.PKIRoleConfig{ 33 | DefaultValidity: server.Spec.Security.Clients.DefaultedValidity(), 34 | RSABits: server.Spec.Security.Clients.DefaultedRSABits(), 35 | Server: false, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pkg/controllers/ovpnserver/deployment.go: -------------------------------------------------------------------------------- 1 | package ovpnserver 2 | 3 | import ( 4 | api "github.com/borchero/meerkat-operator/pkg/api/v1alpha1" 5 | appsv1 "k8s.io/api/apps/v1" 6 | corev1 "k8s.io/api/core/v1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | ) 9 | 10 | const ( 11 | volumeNameConfig = "config" 12 | volumeNameEntrypoint = "entrypoint" 13 | volumeNameTLSKeys = "tls-keys" 14 | volumeNameSharedSecrets = "shared-secrets" 15 | volumeNameCrl = "crl" 16 | 17 | // MountPathOpenVpnConfig is the mount path of the VPN config. 18 | MountPathOpenVpnConfig = "/etc/openvpn" 19 | // MountPathEntrypoint is the mount path of the server entrypoint. 20 | MountPathEntrypoint = "/app" 21 | // MountPathTLSKeys is the mount path of the server TLS keys. 22 | MountPathTLSKeys = "/secrets/tls" 23 | // MountPathSharedSecrets is the mount path of the server shared secrets. 24 | MountPathSharedSecrets = "/secrets/shared" 25 | // MountPathCrl is the mount for the PKI CRL. 26 | MountPathCrl = "/secrets/crl" 27 | 28 | selectorKey = "app.kubernetes.io/name" 29 | ) 30 | 31 | // GetDeploymentSpec returns the expected deployment spec for the given server and the provided 32 | // container image. 33 | func GetDeploymentSpec( 34 | server *api.OvpnServer, image string, additionalPodAnnotations map[string]string, 35 | ) appsv1.DeploymentSpec { 36 | var replicas int32 = 1 37 | var progressDeadline int32 = 600 38 | var revisionLimit int32 = 10 39 | 40 | podAnnotations := map[string]string{} 41 | for k, v := range additionalPodAnnotations { 42 | podAnnotations[k] = v 43 | } 44 | if server.Spec.Deployment.PodAnnotations != nil { 45 | for k, v := range server.Spec.Deployment.PodAnnotations { 46 | podAnnotations[k] = v 47 | } 48 | } 49 | 50 | return appsv1.DeploymentSpec{ 51 | Replicas: &replicas, 52 | ProgressDeadlineSeconds: &progressDeadline, 53 | RevisionHistoryLimit: &revisionLimit, 54 | Strategy: appsv1.DeploymentStrategy{ 55 | Type: appsv1.RecreateDeploymentStrategyType, 56 | }, 57 | Selector: &metav1.LabelSelector{ 58 | MatchLabels: map[string]string{ 59 | selectorKey: server.ObjectRefDeployment().Name, 60 | }, 61 | }, 62 | Template: corev1.PodTemplateSpec{ 63 | ObjectMeta: metav1.ObjectMeta{ 64 | Labels: map[string]string{ 65 | selectorKey: server.ObjectRefDeployment().Name, 66 | }, 67 | Annotations: podAnnotations, 68 | }, 69 | Spec: getPodSpec(server, image), 70 | }, 71 | } 72 | } 73 | 74 | func getPodSpec(server *api.OvpnServer, image string) corev1.PodSpec { 75 | var gracePeriod int64 = 30 76 | return corev1.PodSpec{ 77 | Containers: []corev1.Container{{ 78 | Name: "openvpn", 79 | Image: image, 80 | ImagePullPolicy: corev1.PullIfNotPresent, 81 | SecurityContext: &corev1.SecurityContext{ 82 | Capabilities: &corev1.Capabilities{ 83 | Add: []corev1.Capability{corev1.Capability("NET_ADMIN")}, 84 | }, 85 | }, 86 | VolumeMounts: getVolumeMounts(server), 87 | Resources: corev1.ResourceRequirements{}, 88 | TerminationMessagePath: "/dev/termination-log", 89 | TerminationMessagePolicy: corev1.TerminationMessageReadFile, 90 | }}, 91 | Volumes: getVolumes(server), 92 | RestartPolicy: corev1.RestartPolicyAlways, 93 | DNSPolicy: corev1.DNSClusterFirst, 94 | SchedulerName: corev1.DefaultSchedulerName, 95 | TerminationGracePeriodSeconds: &gracePeriod, 96 | SecurityContext: &corev1.PodSecurityContext{}, 97 | } 98 | } 99 | 100 | func getVolumeMounts(server *api.OvpnServer) []corev1.VolumeMount { 101 | return []corev1.VolumeMount{{ 102 | Name: volumeNameConfig, 103 | MountPath: MountPathOpenVpnConfig, 104 | }, { 105 | Name: volumeNameEntrypoint, 106 | MountPath: MountPathEntrypoint, 107 | }, { 108 | Name: volumeNameTLSKeys, 109 | MountPath: MountPathTLSKeys, 110 | }, { 111 | Name: volumeNameSharedSecrets, 112 | MountPath: MountPathSharedSecrets, 113 | }, { 114 | Name: volumeNameCrl, 115 | MountPath: MountPathCrl, 116 | }} 117 | } 118 | 119 | func getVolumes(server *api.OvpnServer) []corev1.Volume { 120 | var execMode int32 = 0775 121 | var readMode int32 = 0644 122 | return []corev1.Volume{{ 123 | Name: volumeNameConfig, 124 | VolumeSource: corev1.VolumeSource{ 125 | ConfigMap: &corev1.ConfigMapVolumeSource{ 126 | LocalObjectReference: corev1.LocalObjectReference{ 127 | Name: server.ObjectRefOvpnConfigMap().Name, 128 | }, 129 | DefaultMode: &readMode, 130 | }, 131 | }, 132 | }, { 133 | Name: volumeNameEntrypoint, 134 | VolumeSource: corev1.VolumeSource{ 135 | ConfigMap: &corev1.ConfigMapVolumeSource{ 136 | LocalObjectReference: corev1.LocalObjectReference{ 137 | Name: server.ObjectRefEntrypointConfigMap().Name, 138 | }, 139 | DefaultMode: &execMode, 140 | }, 141 | }, 142 | }, { 143 | Name: volumeNameTLSKeys, 144 | VolumeSource: corev1.VolumeSource{ 145 | Secret: &corev1.SecretVolumeSource{ 146 | SecretName: server.ObjectRefServerCertificateSecret().Name, 147 | DefaultMode: &readMode, 148 | }, 149 | }, 150 | }, { 151 | Name: volumeNameSharedSecrets, 152 | VolumeSource: corev1.VolumeSource{ 153 | Secret: &corev1.SecretVolumeSource{ 154 | SecretName: server.ObjectRefSharedSecrets().Name, 155 | DefaultMode: &readMode, 156 | }, 157 | }, 158 | }, { 159 | Name: volumeNameCrl, 160 | VolumeSource: corev1.VolumeSource{ 161 | Secret: &corev1.SecretVolumeSource{ 162 | SecretName: server.ObjectRefCrlSecret().Name, 163 | DefaultMode: &readMode, 164 | }, 165 | }, 166 | }} 167 | } 168 | -------------------------------------------------------------------------------- /pkg/controllers/ovpnserver/service.go: -------------------------------------------------------------------------------- 1 | package ovpnserver 2 | 3 | import ( 4 | api "github.com/borchero/meerkat-operator/pkg/api/v1alpha1" 5 | corev1 "k8s.io/api/core/v1" 6 | "k8s.io/apimachinery/pkg/util/intstr" 7 | ) 8 | 9 | // GetServiceSpec returns the expected service spec for the given server. 10 | func GetServiceSpec(server *api.OvpnServer) corev1.ServiceSpec { 11 | var nodePort int32 12 | if server.Spec.Service.DefaultedServiceType() == corev1.ServiceTypeNodePort { 13 | nodePort = int32(server.Spec.Service.DefaultedPort()) 14 | } 15 | return corev1.ServiceSpec{ 16 | Type: server.Spec.Service.DefaultedServiceType(), 17 | Selector: map[string]string{ 18 | selectorKey: server.ObjectRefDeployment().Name, 19 | }, 20 | Ports: []corev1.ServicePort{{ 21 | Name: "ovpn", 22 | Protocol: server.Spec.Network.DefaultedProtocol(), 23 | Port: int32(server.Spec.Service.DefaultedPort()), 24 | TargetPort: intstr.FromInt(1194), 25 | NodePort: nodePort, 26 | }}, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pkg/controllers/utils.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | // Config describes global configuration for all reconcilers. 4 | type Config struct { 5 | // The reference to the image to use for the OVPN server. 6 | Image string 7 | // The base path to use within Vault for mounting PKIs for the OVPN servers. 8 | PKIPath string `split_words:"true"` 9 | } 10 | -------------------------------------------------------------------------------- /pkg/crypto/dh.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | ) 7 | 8 | // GenerateDhParams generates Diffie-Hellman parameters of the given size and returns the generated 9 | // parameters upon success. This method may take multiple minutes to run. 10 | func GenerateDhParams(bits int) ([]byte, error) { 11 | cmd := exec.Command("openssl", "dhparam", "-out", "/dev/stdout", fmt.Sprintf("%d", bits)) 12 | out, err := cmd.Output() 13 | if err != nil { 14 | return nil, fmt.Errorf("failed generating dh params: %s", err) 15 | } 16 | return out, nil 17 | } 18 | -------------------------------------------------------------------------------- /pkg/crypto/pki.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | 9 | vaultapi "github.com/hashicorp/vault/api" 10 | ) 11 | 12 | // PKI provides a proxy to a Vault instance to manage a PKI. 13 | type PKI struct { 14 | client *vaultapi.Client 15 | path string 16 | } 17 | 18 | // PKICertificate describes a certificate obtained from a PKI. 19 | type PKICertificate struct { 20 | Serial string 21 | Certificate string 22 | PrivateKey string 23 | CACertificate string 24 | Expiration time.Time 25 | } 26 | 27 | // PKICrl represents a certificate revocation list includign non-expired revoked certificates. 28 | type PKICrl struct { 29 | Certificate string 30 | } 31 | 32 | // PKIConfig describes the configuration of a PKI root certificates. Fields that are not set 33 | // explicitly are not added to the root certificate. Common name, rsa bits and validity must be 34 | // set. 35 | type PKIConfig struct { 36 | CommonName string 37 | Validity time.Duration 38 | RSABits int 39 | Organization string 40 | OrganizationalUnit string 41 | Country string 42 | Locality string 43 | } 44 | 45 | // PKIRoleConfig describes the configuration of a role. 46 | type PKIRoleConfig struct { 47 | DefaultValidity time.Duration 48 | RSABits int 49 | Server bool 50 | } 51 | 52 | // NewPKI returns a new PKI at the given path. Possibly, the PKI is not yet initialized. 53 | func NewPKI(vault *vaultapi.Client, path string) *PKI { 54 | return &PKI{client: vault, path: path} 55 | } 56 | 57 | // EnsureEnabled makes sure that the PKI is enabled at the given path. 58 | func (pki *PKI) EnsureEnabled() error { 59 | mounts, err := pki.client.Sys().ListMounts() 60 | if err != nil { 61 | return fmt.Errorf("failed to list existing mount paths: %s", err) 62 | } 63 | 64 | // If the mounts contain the path, it is already enabled 65 | if _, ok := mounts[pki.path+"/"]; ok { 66 | return nil 67 | } 68 | 69 | // Otherwise, we create it 70 | input := &vaultapi.MountInput{ 71 | Type: "pki", 72 | Config: vaultapi.MountConfigInput{ 73 | DefaultLeaseTTL: "2592000", // 30 days 74 | MaxLeaseTTL: "315360000", // 10 years 75 | }, 76 | } 77 | if err := pki.client.Sys().Mount(pki.path, input); err != nil { 78 | return fmt.Errorf("failed to create mount for PKI: %s", err) 79 | } 80 | 81 | // Also, we need to configure the CRL 82 | path := fmt.Sprintf("%s/config/crl", pki.path) 83 | content := map[string]interface{}{ 84 | "expiry": "72h", 85 | "disable": false, 86 | } 87 | if _, err := pki.client.Logical().Write(path, content); err != nil { 88 | return fmt.Errorf("failed to set CRL configuration: %s", err) 89 | } 90 | return nil 91 | } 92 | 93 | // DisableIfEnabled disables the engine backing the PKI if it exists. 94 | func (pki *PKI) DisableIfEnabled() error { 95 | if err := pki.client.Sys().Unmount(pki.path); err != nil { 96 | return fmt.Errorf("failed to disable PKI: %s", err) 97 | } 98 | return nil 99 | } 100 | 101 | // GenerateRootIfRequired generates the internal private key and certificate of the PKI or does 102 | // nothing if it already exists. 103 | func (pki *PKI) GenerateRootIfRequired(config PKIConfig) error { 104 | path := fmt.Sprintf("%s/root/generate/internal", pki.path) 105 | contents := map[string]interface{}{ 106 | "common_name": config.CommonName, 107 | "key_type": "rsa", 108 | "key_bits": config.RSABits, 109 | "ttl": fmt.Sprintf("%ds", int(config.Validity.Seconds())), 110 | "exclude_cn_from_sans": true, 111 | } 112 | if config.Organization != "" { 113 | contents["organization"] = config.Organization 114 | } 115 | if config.OrganizationalUnit != "" { 116 | contents["ou"] = config.OrganizationalUnit 117 | } 118 | if config.Country != "" { 119 | contents["country"] = config.Country 120 | } 121 | if config.Locality != "" { 122 | contents["locality"] = config.Locality 123 | } 124 | if _, err := pki.client.Logical().Write(path, contents); err != nil { 125 | return fmt.Errorf("failed to verify and possibly create root key: %s", err) 126 | } 127 | return nil 128 | } 129 | 130 | // ConfigureRole configures the role with the given name. If the role doesn't exist yet, it is 131 | // created, otherwise it is updated. 132 | func (pki *PKI) ConfigureRole(name string, config PKIRoleConfig) error { 133 | var extensions []string 134 | if config.Server { 135 | extensions = []string{"TLS Web Server Authentication"} 136 | } else { 137 | extensions = []string{"TLS Web Client Authentication"} 138 | } 139 | 140 | path := fmt.Sprintf("%s/roles/%s", pki.path, name) 141 | contents := map[string]interface{}{ 142 | "key_type": "rsa", 143 | "key_bits": config.RSABits, 144 | "ttl": fmt.Sprintf("%ds", int(config.DefaultValidity.Seconds())), 145 | "max_ttl": "87600h", 146 | "allow_any_name": true, 147 | "server_flag": config.Server, 148 | "client_flag": !config.Server, 149 | "generate_lease": false, 150 | "not_before_duration": "15m", 151 | "key_usage": []string{"DigitalSignature", "KeyAgreement", "KeyEncipherment"}, 152 | "ext_key_usage": extensions, 153 | } 154 | if _, err := pki.client.Logical().Write(path, contents); err != nil { 155 | return fmt.Errorf("failed to update role configuration: %s", err) 156 | } 157 | return nil 158 | } 159 | 160 | // Generate generates a new certificate for the provided role with the given common name. If the 161 | // validity is greater than 0, it replaces the default validity. 162 | func (pki *PKI) Generate(role, commonName string, validity time.Duration) (PKICertificate, error) { 163 | path := fmt.Sprintf("%s/issue/%s", pki.path, role) 164 | contents := map[string]interface{}{ 165 | "common_name": commonName, 166 | "format": "pem", 167 | } 168 | if validity > 0 { 169 | contents["ttl"] = fmt.Sprintf("%ds", int(validity.Seconds())) 170 | } 171 | result, err := pki.client.Logical().Write(path, contents) 172 | if err != nil { 173 | return PKICertificate{}, fmt.Errorf("failed to generate certificate: %s", err) 174 | } 175 | expiration, err := result.Data["expiration"].(json.Number).Int64() 176 | if err != nil { 177 | return PKICertificate{}, fmt.Errorf("invalid expiration date: %s", err) 178 | } 179 | 180 | return PKICertificate{ 181 | Serial: result.Data["serial_number"].(string), 182 | Certificate: result.Data["certificate"].(string), 183 | PrivateKey: result.Data["private_key"].(string), 184 | CACertificate: result.Data["issuing_ca"].(string), 185 | Expiration: time.Unix(expiration, 0), 186 | }, nil 187 | } 188 | 189 | // Revoke revokes the certificate with the given serial. 190 | func (pki *PKI) Revoke(serial string) error { 191 | path := fmt.Sprintf("%s/revoke", pki.path) 192 | contents := map[string]interface{}{ 193 | "serial_number": serial, 194 | } 195 | if _, err := pki.client.Logical().Write(path, contents); err != nil { 196 | return fmt.Errorf("failed to revoke certificate: %s", err) 197 | } 198 | return nil 199 | } 200 | 201 | // GetCRL returns the revocation list for this PKI. The CRL is automatically rotated if its 202 | // expiration date is within the next 24 hours. 203 | func (pki *PKI) GetCRL() (PKICrl, error) { 204 | // First, we get the certificate and check whether it expires soon 205 | crl, err := pki.readCRL() 206 | if err != nil { 207 | return PKICrl{}, err 208 | } 209 | expiration, err := pki.crlExpiration(crl) 210 | if err != nil { 211 | return PKICrl{}, err 212 | } 213 | if expiration.Sub(time.Now()) >= 24*time.Hour { 214 | return PKICrl{Certificate: crl}, nil 215 | } 216 | 217 | // Otherwise, we rotate it 218 | rotationPath := fmt.Sprintf("%s/crl/rotate", pki.path) 219 | if _, err := pki.client.Logical().Read(rotationPath); err != nil { 220 | return PKICrl{}, fmt.Errorf("failed to rotate CRL: %s", err) 221 | } 222 | 223 | // And then we can request it again 224 | crl, err = pki.readCRL() 225 | if err != nil { 226 | return PKICrl{}, err 227 | } 228 | return PKICrl{Certificate: crl}, nil 229 | } 230 | 231 | func (pki *PKI) readCRL() (string, error) { 232 | path := fmt.Sprintf("%s/cert/crl", pki.path) 233 | result, err := pki.client.Logical().Read(path) 234 | if err != nil { 235 | return "", fmt.Errorf("failed to read CRL: %s", err) 236 | } 237 | return result.Data["certificate"].(string), nil 238 | } 239 | 240 | func (pki *PKI) crlExpiration(crl string) (time.Time, error) { 241 | cert, err := x509.ParseCRL([]byte(crl)) 242 | if err != nil { 243 | return time.Time{}, fmt.Errorf("failed to parse CRL: %s", err) 244 | } 245 | return cert.TBSCertList.NextUpdate, nil 246 | } 247 | -------------------------------------------------------------------------------- /pkg/crypto/tls.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | ) 7 | 8 | // GenerateTLSAuth generates an OpenVPN static key to be used. 9 | func GenerateTLSAuth() ([]byte, error) { 10 | cmd := exec.Command("openvpn", "--genkey", "--secret", "/dev/stdout") 11 | out, err := cmd.Output() 12 | if err != nil { 13 | return nil, fmt.Errorf("failed generating tls auth: %s", err) 14 | } 15 | return out, nil 16 | } 17 | -------------------------------------------------------------------------------- /pkg/crypto/vault.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "strings" 7 | "sync" 8 | "time" 9 | 10 | "github.com/fsnotify/fsnotify" 11 | vaultapi "github.com/hashicorp/vault/api" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | // VaultConfig describes the configuration for a Vault instance. 16 | type VaultConfig struct { 17 | Addr string `required:"true"` 18 | TokenMount string `required:"true" split_words:"true"` 19 | CaCrt string `split_words:"true"` 20 | ServerName string `split_words:"true"` 21 | } 22 | 23 | // EnsureTokenUpdated enters an infinite loop that watches the given target file and updates the 24 | // token associated with the client whenever the token changes. 25 | func EnsureTokenUpdated( 26 | ctx context.Context, client *vaultapi.Client, target string, logger *zap.Logger, 27 | ) { 28 | watcher, err := fsnotify.NewWatcher() 29 | if err != nil { 30 | logger.Error("failed to initialize file watcher", zap.Error(err)) 31 | return 32 | } 33 | defer watcher.Close() 34 | 35 | wg := sync.WaitGroup{} 36 | wg.Add(1) 37 | 38 | go func() { 39 | loop: 40 | for { 41 | select { 42 | case <-ctx.Done(): 43 | break loop 44 | case event, ok := <-watcher.Events: 45 | if !ok { 46 | break loop 47 | } 48 | if (event.Op&fsnotify.Write > 0) || (event.Op&fsnotify.Create > 0) { 49 | setTokenFromFile(client, target, logger) 50 | } 51 | case err, ok := <-watcher.Errors: 52 | if !ok { 53 | break loop 54 | } 55 | logger.Warn("received error from token watcher", zap.Error(err)) 56 | } 57 | } 58 | wg.Done() 59 | }() 60 | 61 | for i := 0; i < 16; i++ { 62 | if err := watcher.Add(target); err != nil { 63 | logger.Error("failed to add file to file watcher, retrying", zap.Error(err)) 64 | select { 65 | case <-ctx.Done(): 66 | break 67 | case <-time.After(time.Second << i): 68 | continue 69 | } 70 | } 71 | setTokenFromFile(client, target, logger) 72 | break 73 | } 74 | wg.Wait() 75 | } 76 | 77 | func setTokenFromFile(client *vaultapi.Client, target string, logger *zap.Logger) { 78 | content, err := ioutil.ReadFile(target) 79 | if err != nil { 80 | logger.Warn("failed to read file containing client token", zap.Error(err)) 81 | } else { 82 | client.SetToken(strings.Trim(string(content), " \n\t")) 83 | logger.Info("successfully set new client token") 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /pkg/ovpn/certificate.go: -------------------------------------------------------------------------------- 1 | package ovpn 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/borchero/meerkat-operator/pkg/ovpn/static" 7 | ) 8 | 9 | // CertificateValues describes the set of values required to render OVPN certificates. 10 | type CertificateValues struct { 11 | Secrets CertificateSecrets 12 | Host string 13 | Port uint16 14 | Protocol string 15 | Security ConfigSecurity 16 | } 17 | 18 | // CertificateSecrets contains all relevant secrets for generating an OVPN client file. 19 | type CertificateSecrets struct { 20 | TLSClientKey string 21 | TLSClientCrt string 22 | TLSCaCrt string 23 | TLSAuth string 24 | } 25 | 26 | // GetCertificate generates the OVPN certificate file that is given to the clients. 27 | func GetCertificate(values CertificateValues) (string, error) { 28 | certificate, err := renderTemplate("certificate", static.TemplateClient, values) 29 | if err != nil { 30 | return "", err 31 | } 32 | return strings.Trim(certificate, "\n\t\r "), nil 33 | } 34 | -------------------------------------------------------------------------------- /pkg/ovpn/config.go: -------------------------------------------------------------------------------- 1 | package ovpn 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/borchero/meerkat-operator/pkg/ovpn/static" 7 | ) 8 | 9 | // ConfigValues describes the set of values required to render the OVPN config file. 10 | type ConfigValues struct { 11 | Files ConfigFiles 12 | Routes []ConfigRoute 13 | Nameservers []string 14 | RedirectAll bool 15 | Protocol string 16 | Security ConfigSecurity 17 | } 18 | 19 | // ConfigFiles describes the set of file paths required for the OVPN config file. 20 | type ConfigFiles struct { 21 | TLSServerCrt string 22 | TLSServerKey string 23 | TLSCaCrt string 24 | DHParams string 25 | TLSAuth string 26 | CRL string 27 | } 28 | 29 | // ConfigRoute describes a route for the OVPN config file, consisting of IP and subnet mask. 30 | type ConfigRoute struct { 31 | IP string 32 | Mask string 33 | } 34 | 35 | // ConfigSecurity describe the security configuration for the OVPN config file. 36 | type ConfigSecurity struct { 37 | Hmac string 38 | Cipher string 39 | } 40 | 41 | // GetConfig returns a OVPN config for the given files and configuration. 42 | func GetConfig(values ConfigValues) (string, error) { 43 | config, err := renderTemplate("config", static.TemplateConfig, values) 44 | if err != nil { 45 | return "", err 46 | } 47 | return strings.Trim(config, "\n\t\r "), nil 48 | } 49 | -------------------------------------------------------------------------------- /pkg/ovpn/entrypoint.go: -------------------------------------------------------------------------------- 1 | package ovpn 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/borchero/meerkat-operator/pkg/ovpn/static" 7 | ) 8 | 9 | // EntrypointValues describes the set of values required to render the OVPN server entrypoint. 10 | type EntrypointValues struct { 11 | Routes []string 12 | } 13 | 14 | // GetEntrypoint returns the file that should be used for starting the VPN server. It sets up IP 15 | // tables according to the given configuration. 16 | func GetEntrypoint(values EntrypointValues) (string, error) { 17 | entrypoint, err := renderTemplate("entrypoint", static.TemplateEntrypoint, values) 18 | if err != nil { 19 | return "", err 20 | } 21 | return strings.Trim(entrypoint, "\n\t\r "), nil 22 | } 23 | -------------------------------------------------------------------------------- /pkg/ovpn/parse.go: -------------------------------------------------------------------------------- 1 | package ovpn 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | api "github.com/borchero/meerkat-operator/pkg/api/v1alpha1" 8 | ) 9 | 10 | // ParseRoutes is a utility function to convert the api's subnet masks into routes for the OVPN 11 | // config file. 12 | func ParseRoutes(subnets []api.SubnetMask) []ConfigRoute { 13 | result := make([]ConfigRoute, len(subnets)) 14 | for i, subnet := range subnets { 15 | splits := strings.Split(string(subnet), "/") 16 | result[i].IP = splits[0] 17 | result[i].Mask = getMask(splits[1]) 18 | } 19 | return result 20 | } 21 | 22 | // ParseRoutesString is a utility function to convert the api's subnet masks into iptable routes. 23 | func ParseRoutesString(subnets []api.SubnetMask) []string { 24 | result := make([]string, len(subnets)) 25 | for i, subnet := range subnets { 26 | splits := strings.Split(string(subnet), "/") 27 | result[i] = splits[0] + "/" + getMask(splits[1]) 28 | } 29 | return result 30 | } 31 | 32 | func getMask(stringSize string) string { 33 | size, err := strconv.Atoi(stringSize) 34 | if err != nil { 35 | panic(err) 36 | } 37 | mask := 0xFFFFFFFF << (32 - size) 38 | limbs := []string{ 39 | strconv.Itoa((mask >> 24) & 0xFF), 40 | strconv.Itoa((mask >> 16) & 0xFF), 41 | strconv.Itoa((mask >> 8) & 0xFF), 42 | strconv.Itoa(mask & 0xFF), 43 | } 44 | return strings.Join(limbs, ".") 45 | } 46 | -------------------------------------------------------------------------------- /pkg/ovpn/static/client.go: -------------------------------------------------------------------------------- 1 | package static 2 | 3 | // TemplateClient contains the template for the OVPN client file. 4 | const TemplateClient = ` 5 | client 6 | nobind 7 | dev tun 8 | remote-cert-tls server 9 | remote {{ .Host }} {{ .Port }} {{ .Protocol | lower }} 10 | 11 | 12 | {{ .Secrets.TLSClientKey | trim }} 13 | 14 | 15 | {{ .Secrets.TLSClientCrt | trim }} 16 | 17 | 18 | {{ .Secrets.TLSCaCrt | trim }} 19 | 20 | 21 | 22 | {{ .Secrets.TLSAuth | trim }} 23 | 24 | 25 | auth {{ .Security.Hmac }} 26 | cipher {{ .Security.Cipher }} 27 | ` 28 | -------------------------------------------------------------------------------- /pkg/ovpn/static/config.go: -------------------------------------------------------------------------------- 1 | package static 2 | 3 | // TemplateConfig includes the template for the OVPN server config file. 4 | const TemplateConfig = ` 5 | user nobody 6 | group nogroup 7 | 8 | status /tmp/openvpn.log 9 | {{ if eq .Protocol "UDP" -}} 10 | explicit-exit-notify 1 11 | {{ end -}} 12 | 13 | server 192.168.255.0 255.255.255.0 14 | port 1194 15 | proto {{ .Protocol | lower }} 16 | dev tun0 17 | 18 | cert {{ .Files.TLSServerCrt }} 19 | key {{ .Files.TLSServerKey }} 20 | ca {{ .Files.TLSCaCrt }} 21 | dh {{ .Files.DHParams }} 22 | tls-crypt {{ .Files.TLSAuth }} 23 | crl-verify {{ .Files.CRL }} 24 | 25 | auth {{ .Security.Hmac }} 26 | cipher {{ .Security.Cipher }} 27 | 28 | keepalive 10 60 29 | key-direction 0 30 | persist-key 31 | persist-tun 32 | verb 3 33 | 34 | push "route 192.168.255.0 255.255.255.0" 35 | {{ range .Routes -}} 36 | push "route {{ .IP }} {{ .Mask }}" 37 | {{ end -}} 38 | {{ range .Nameservers -}} 39 | push "dhcp-option DNS {{ . }}" 40 | {{ end -}} 41 | {{ if .RedirectAll -}} 42 | push "redirect-gateway def1" 43 | {{ end -}} 44 | ` 45 | -------------------------------------------------------------------------------- /pkg/ovpn/static/entrypoint.go: -------------------------------------------------------------------------------- 1 | package static 2 | 3 | // TemplateEntrypoint contains the template for the OVPN server entrypoint. 4 | const TemplateEntrypoint = ` 5 | #!/bin/sh 6 | 7 | set -o errexit 8 | 9 | iptables -t nat -A POSTROUTING -s 192.168.255.0/255.255.255.0 -o eth0 -j MASQUERADE 10 | {{ range .Routes -}} 11 | iptables -t nat -A POSTROUTING -s {{ . }} -o eth0 -j MASQUERADE 12 | {{ end -}} 13 | 14 | mkdir -p /dev/net 15 | if [ ! -c /dev/net/tun ]; then 16 | mknod /dev/net/tun c 10 200 17 | fi 18 | 19 | # Exec to receive termination signals 20 | exec openvpn --config /etc/openvpn/openvpn.conf 21 | ` 22 | -------------------------------------------------------------------------------- /pkg/ovpn/template.go: -------------------------------------------------------------------------------- 1 | package ovpn 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "text/template" 7 | 8 | "github.com/Masterminds/sprig/v3" 9 | ) 10 | 11 | func renderTemplate(name, templateString string, values interface{}) (string, error) { 12 | // And render it 13 | t, err := template.New(name).Funcs(sprig.TxtFuncMap()).Parse(templateString) 14 | if err != nil { 15 | return "", fmt.Errorf("failed to parse template: %s", err) 16 | } 17 | var result bytes.Buffer 18 | if err := t.Execute(&result, values); err != nil { 19 | return "", fmt.Errorf("failed to render template: %s", err) 20 | } 21 | return result.String(), nil 22 | } 23 | -------------------------------------------------------------------------------- /tests/env/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borchero/meerkat/09e4b41b46c1f9ab50306354f28982a8618b22f3/tests/env/.gitkeep -------------------------------------------------------------------------------- /tests/manifests/client.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: meerkat.borchero.com/v1alpha1 2 | kind: OvpnClient 3 | metadata: 4 | name: foo 5 | spec: 6 | serverName: test 7 | commonName: foo@borchero.com 8 | -------------------------------------------------------------------------------- /tests/manifests/server.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: meerkat.borchero.com/v1alpha1 2 | kind: OvpnServer 3 | metadata: 4 | name: test 5 | spec: 6 | network: 7 | host: vpn.borchero.com 8 | --------------------------------------------------------------------------------