Contents

Secure Your Kubernetes Applications with Self-Signed Certificates

This concise guide outlines how to secure Kubernetes applications by generating and deploying self-signed certificates using OpenSSL. It details the steps from creating your own certificate to configuring Kubernetes secrets and Ingress resources for SSL encryption.

Introduction

Securing communication in microservices is a fundamental step to protect sensitive data and enforce privacy standards. Kubernetes supports several methods for securing applications. One such approach is to use SSL/TLS for encrypting traffic, and a cost-effective way to achieve this is by utilizing self-signed certificates.

In this blog post, I will walk you through the steps to secure your Kubernetes applications using self-signed certificates.

Let’s get to it.


The Application

Our application is a basic HTTP server that serves a list of to-do items. Here’s the complete code with comments for clarity:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package main

import (
	"encoding/json" // Provides functions for encoding and decoding JSON data
	"log/slog"      // A package for structured logging
	"net/http"      // Provides HTTP client and server implementations
	"time"          // Provides functionality for measuring and displaying time

	"github.com/google/uuid" // Generates universally unique identifiers (UUIDs)
)

// The port on which the HTTP server will listen for incoming requests.
const port = ":80"

func main() {
	// Register the 'handleTodo' function to handle requests to the '/todo' path.
	http.HandleFunc("/todo", handleTodo)

	slog.Info("starting HTTP server", "port", port)

    // Starts the HTTP server on the specified port.
	err := http.ListenAndServe(port, nil)
	if err != nil {
		slog.Error("server failure", "error", err)
	}
}

// handleTodo handles the /todo URL path
func handleTodo(w http.ResponseWriter, _ *http.Request) {
	slog.Info("request received at path /todo")

    // Set the 'Content-Type' header and HTTP status code of the reponse.
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)

    // Get a list of to-do items.
	todoItems := getTodoList()

    // Encodes the list of to-do items as JSON and writes it to the HTTP response.
	err := json.NewEncoder(w).Encode(todoItems)
	if err != nil {
		slog.Error("failed to write response", "error", err)
	}
}

// getTodoList returns a list of to-do items
func getTodoList() []todoItem {
	return []todoItem{
		{
			DueDate: time.Now().AddDate(0, 0, 7),
			ID:      uuid.NewString(),
			Title:   "write a todo-app",
		},
		{
			DueDate: time.Now().AddDate(0, 0, 8),
			ID:      uuid.NewString(),
			Title:   "define K8s manifests",
		},
		{
			DueDate: time.Now().AddDate(0, 0, 9),
			ID:      uuid.NewString(),
			Title:   "use certificates",
		},
	}
}

// todoItem defines a to-do item
type todoItem struct {
    // Due date of the to-do item
	DueDate time.Time `json:"dueDate"`

    // A unique identifier for the to-do item
	ID      string    `json:"id"`

    // Title of the to-do item
	Title   string    `json:"title"`
}

The above program creates an HTTP server that responds to /todo requests with a predefined list of to-do items in JSON format. It uses UUIDs for unique identifiers and sets due dates in the future. The server logs important information and errors using the slog package.

Generate Self-Signed Certificate

As the next step, we need to create a self-signed certificate for our application. In order to do that, we will be using OpenSSL.

Following are the steps to generate the self-signed certificate using OpenSSL:

  • Generate an RSA private key

    1
    
    openssl genrsa -out tls.key 4096
    

  • Generate a CSR (Certificate Signing Request) with the required CN and Subject Alternative Names (SANs). Use the openssl req command with the -subj and -addext options:

    1
    2
    3
    
    openssl req -new -key tls.key -out tls.csr \
      -subj "/CN=todo-app" -addext \
      "subjectAltName=DNS:todo-app.default.svc.cluster.local,DNS:localhost,DNS:todo-app"
    

  • Generate the Self-Signed Certificate using the information from the CSR:

    1
    2
    3
    
    openssl x509 -req -days 365 -in tls.csr -signkey tls.key \
      -out tls.crt -extensions req_ext \
      -extfile <(printf "[req_ext]\nsubjectAltName=DNS:todo-app.default.svc.cluster.local,DNS:localhost,DNS:todo-app")
    

  • (Optional) To view the content of the generated certificate in a human-readable form, you can use the openssl command-line tool to read the certificate and display its details.

    1
    
    openssl x509 -in tls.crt -text -noout
    

Cluster Setup

We can leverage Kind’s extraPortMapping config option when creating a cluster to forward ports from the host to an ingress controller running on a node.

Create a kind cluster with extraPortMappings and node-labels.

  • extraPortMappings allow the local host to make requests to the Ingress controller over ports 80/443
  • node-labels only allow the ingress controller to run on a specific node(s) matching the label selector
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Three node cluster with an ingress-ready control-plane node
# and extra port mappings over 80/443 and 2 workers.

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
    kubeadmConfigPatches:
      - |
        kind: InitConfiguration
        nodeRegistration:
          kubeletExtraArgs:
            node-labels: "ingress-ready=true"        
    extraPortMappings:
      - containerPort: 80
        hostPort: 80
        protocol: TCP
      - containerPort: 443
        hostPort: 443
        protocol: TCP
  - role: worker
  - role: worker

Check the cluster state:

1
2
3
4
5
6
7
export KUBECONFIG=~/.kube/kind.yaml

➜ kubectl get nodes
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   7d20h   v1.27.3
kind-worker          Ready    <none>          7d20h   v1.27.3
kind-worker2         Ready    <none>          7d20h   v1.27.3

Ingress NGINX

1
2
kubectl apply -f \
  https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml

The manifests contains kind specific patches to forward the hostPorts to the ingress controller, set taint tolerations and schedule it to the custom labelled node.

Now the Ingress is all setup. Wait until it’s ready to process requests by running:

1
2
3
4
kubectl wait --namespace ingress-nginx \
  --for=condition=ready pod \
  --selector=app.kubernetes.io/component=controller \
  --timeout=90s

Deploying the Application

As the first step, let’s create a namespace called todo, where will deploy our application and it’s supporting resources.

1
kubectl create namespace todo

Next, we need to create a Kubernetes TLS Secret to store the self-signed certificate generated in a previous step.

1
kubectl create secret -n todo tls todo-app --cert tls.crt --key tls.key

Now, let’s use the following YAML manifest to deploy our application:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
kubectl apply -f - << EOF
apiVersion: apps/v1
kind: Deployment
metadata:
  name: todo-app
  namespace: todo
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: todo-app
  template:
    metadata:
      labels:
        app.kubernetes.io/name: todo-app
    spec:
      containers:
        - name: todo-app
          image: todo-app:v0.1.0
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: todo-app
  namespace: todo
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: todo-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: todo-app
  namespace: todo
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - "todo-app.default.svc.cluster.local"
        - "localhost"
        - "todo-app"
      secretName: todo-app
  rules:
    - http:
        paths:
          - pathType: Prefix
            path: "/todo"
            backend:
              service:
                name: todo-app
                port:
                  number: 80
EOF

This will create a deployment, a service, and an ingress resource for the todo-app.


Testing

We can use the curl utility to test if we can reach our server:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
➜ curl --cacert tls.crt https://localhost/todo

  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

And, it seems that there is an issue with certificate verification.

Let’s try to use curl with the -k option which allows to establish an insecure connection:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
➜ curl -kv https://localhost/todo
*   Trying [::1]:443...
* Connected to localhost (::1) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-AES256-GCM-SHA384
* ALPN: server accepted h2
* Server certificate:
*  subject: O=Acme Co; CN=Kubernetes Ingress Controller Fake Certificate
*  start date: Sep 29 08:28:21 2024 GMT
*  expire date: Sep 29 08:28:21 2025 GMT
*  issuer: O=Acme Co; CN=Kubernetes Ingress Controller Fake Certificate
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://localhost/todo
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: localhost]
* [HTTP/2] [1] [:path: /todo]
* [HTTP/2] [1] [user-agent: curl/8.4.0]
* [HTTP/2] [1] [accept: */*]
> GET /todo HTTP/2
> Host: localhost
> User-Agent: curl/8.4.0
> Accept: */*
>
< HTTP/2 200
< date: Sun, 29 Sep 2024 09:16:45 GMT
< content-type: application/json
< content-length: 354
< strict-transport-security: max-age=31536000; includeSubDomains
<
[{"dueDate":"2024-10-06T09:16:45.708178138Z","id":"38afce86-0a6c-4b00-8d47-4dd0d836b89c","title":"write a todo-app"},{"dueDate":"2024-10-07T09:16:45.708189179Z","id":"4e42c873-b6cb-47b2-9d6b-0f476b59c332","title":"define K8s manifests"},{"dueDate":"2024-10-08T09:16:45.708191304Z","id":"dc8385d6-4c19-4605-b65d-3ad1ce2102fc","title":"use certificates"}]
* Connection #0 to host localhost left intact

If you look closely, you will notice that the server is using a fake certificate. This is why our previous request with --cacert option failed to verify.

Let’s fix that by updating the default certificate used by the server.

Default SSL Certificate

The Ingress NGINX controller documentation states that:

For HTTPS, a certificate is naturally required. For this reason the Ingress controller provides the flag --default-ssl-certificate. The secret referred to by this flag contains the default certificate to be used when accessing the catch-all server. If this flag is not provided NGINX will use a self-signed certificate.

The complete documentation can be found here.

We can patch the NGINX Ingress controller deployment to use the certificate from todo/todo-app (namespace/secret-name) TLS secret as the default SSL certificate:

1
2
3
4
5
6
7
8
9
kubectl patch deployment \
  ingress-nginx-controller \
  --namespace "ingress-nginx" \
  --type='json' \
  -p='[{
    "op": "add",
    "path": "/spec/template/spec/containers/0/args/-",
    "value": "--default-ssl-certificate=todo/todo-app"
  }]'

Give it a few seconds so that the change is applied, and try again.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
➜ curl -s --cacert tls.crt https://localhost/todo | jq .
[
  {
    "dueDate": "2024-10-06T09:25:40.344474844Z",
    "id": "f8b2d51d-f9d7-4468-a78b-f187e5fb7db7",
    "title": "write a todo-app"
  },
  {
    "dueDate": "2024-10-07T09:25:40.344487552Z",
    "id": "6506e497-208b-4a70-8181-2e6d3460738b",
    "title": "define K8s manifests"
  },
  {
    "dueDate": "2024-10-08T09:25:40.344489594Z",
    "id": "6a2c7af4-07e3-45f3-a04d-8aafcac2e4bc",
    "title": "use certificates"
  }
]

Congratulations!! 🥳

Conclusion

The post aimed to outline the steps to generate and deploy self-signed certificates using OpenSSL, configure Kubernetes secrets, and set up Ingress resources for SSL encryption. This gives you the foundational knowledge to enhance the security of your microservices and ensure encrypted communication.

Remember, as your applications grow and move closer to production, transitioning to certificates issued by trusted Certificate Authorities (CAs) would provide more security and reliability, especially for public-facing services.

If you find it helpful, stay tuned for more. Happy securing!