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:
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
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:
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:
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.
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 ports80/443
-
node-labels
only allow the ingress controller to run on a specific node(s) matching the label selector
# 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:
➜ 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
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:
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.
kubectl create namespace todo
Next, we need to create a Kubernetes TLS Secret to store the self-signed certificate generated in a previous step.
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:
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:
➜ 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:
➜ 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:
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.
➜ 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!
Top comments (0)