on
Writing a Kubernetes Admission Controller
With the deprecation of PSP on Kubernetes v1.21, we will have to migrate to other methods to control the resource permissions in a cluster.
One case that I wanted to handle was running an untrusted job in a cluster, to help review student’s homework and leveraging kubernetes for resource allocation and security. Here we will explore how to develop a new admission controller, that will verify the fields of new jobs on a given namespace are secure enough to run untrusted code in a safe way.
Recommended policy enforcement applications
If you want to start defining policies for a production cluster, you will probably want to use a ready to use application, which have predefined policies, and setup custom policies easily by using custom resources. Some of them are:
Cluster requirements
The api-server must have the plugin ValidatingAdmissionWebhook enabled (If you want to modify the resources, you also need MutatingAdmissionWebhook). Note that these plugins are disabled by default in a kind cluster.
Application goal
In our example, we will write a validating admission webhook (So we will not modify the resource) that will check new jobs created in a namespace, verifying as many security options of the pod as we can (running as non-root, using gvisor as sandbox, and many others). The target container image that we are targeting is an untrusted job that can be potentially malicious.
Writing the admission controller
Our admission controller will be written in Go, but you can use any language you know as the api use normal https json requests.
I will be trimming some of the code to make it more readable. The full source code can be found at https://github.com/fdns/simple-admission
Listening to admission requests
First, we will need to create a HTTPS listener (TLS is mandatory). You can use any http path to serve the requests, but you must update the manifest afterwards with the correct location when we define the ValidatingAdmissionWebhook.
func main() {
// ...
certs, err := tls.LoadX509KeyPair(certFile, keyFile)
server := &http.Server{
Addr: fmt.Sprintf(":%v", port),
TLSConfig: &tls.Config{
Certificates: []tls.Certificate{certs},
},
}
// Define server handler
handler := AdmissionHandler{
RuntimeClass: runtimeClass,
}
mux := http.NewServeMux()
mux.HandleFunc("/validate", handler.handler)
server.Handler = mux
go func() {
log.Printf("Listening on port %v", port)
if err := server.ListenAndServeTLS("", ""); err != nil {
log.Printf("Failed to listen and serve webhook server: %v", err)
os.Exit(1)
}
}()
// Listen to the shutdown signal
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
<-signalChan
log.Printf("Shutting down webserver")
server.Shutdown(context.Background())
}
Handling admission request
When receiving the request, you must load the body as an AdmissionReview object. This object contains all the information of the objects that is being created.
import (
admission "k8s.io/api/admission/v1beta1"
batchv1 "k8s.io/api/batch/v1"
k8meta "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func (handler *AdmissionHandler) handler(w http.ResponseWriter, r *http.Request) {
var body []byte
if r.Body != nil {
data, err := ioutil.ReadAll(r.Body)
if err == nil {
body = data
} else {
log.Printf("Error %v", err)
http.Error(w, "Error reading body", http.StatusBadRequest)
return
}
}
request := admission.AdmissionReview{}
if err := json.Unmarshal(body, &request); err != nil {
log.Printf("Error parsing body %v", err)
http.Error(w, "Error parsing body", http.StatusBadRequest)
return
}
result, err := checkRequest(request.Request, handler)
// ...
}
Validating the request
In the checkRequest function, we will check if we can handle the resource, verifying the resource group, kind, operation and namespace.
func checkRequest(request *admission.AdmissionRequest, handler *AdmissionHandler) (bool, error) {
if request.RequestKind.Group != "batch" || request.RequestKind.Kind != "Job" || request.Operation != "CREATE" {
log.Printf("Skipped resource [%v,%v,%v], check rules to exclude this resource", request.RequestKind.Group, request.RequestKind.Kind, request.Operation)
return true, nil
}
// ...
}
The resource body (In our case, a Job) must un unmarshal again before we can verify the parameters.
var job *batchv1.Job
err := json.Unmarshal(request.Object.Raw, &job)
if err != nil {
log.Printf("Error parsing job %v", err)
return true, nil
}
return checkJob(job, handler)
Checking the resource
On the checkJob, we will have full access to the resource parameters. Most of the parameters that are not defined will be nil, so you must verify that the parameters is defined before getting its value.
I will copy some of the rules as an example, and the full list that I defined can be found here.
func checkJob(request *batchv1.Job, handler *AdmissionHandler) (bool, error) {
if request.Spec.ActiveDeadlineSeconds == nil || *request.Spec.ActiveDeadlineSeconds == 0 {
return false, fmt.Errorf("activeDeadlineSeconds must be set")
}
spec := request.Spec.Template.Spec
if spec.RuntimeClassName == nil || *spec.RuntimeClassName != handler.RuntimeClass {
return false, fmt.Errorf("wrong RuntimeClass %v is set for job %v, must be %v", spec.RuntimeClassName, request.Name, handler.RuntimeClass)
}
if spec.HostNetwork != false {
return false, fmt.Errorf("HostNetwork must not be set")
}
if spec.SecurityContext != nil && len(spec.SecurityContext.Sysctls) > 0 {
return false, fmt.Errorf("Sysctls must be empty")
}
// ...
for _, container := range spec.Containers {
if container.SecurityContext == nil {
return false, fmt.Errorf("SecurityContext must be set for the container")
}
context := *container.SecurityContext
if context.RunAsNonRoot == nil || *context.RunAsNonRoot != true {
return false, fmt.Errorf("RunAsNonRoot must be set per container")
}
// ...
}
return true, nil
}
Returning to the api-server
After doing all the validations, you must return an AdmissionResponse object that is json encoded. In this object we will define if the objects is allowed or not in our cluster. We can also append a message that will be displayed when the resource is not allowed, so the developer can fix the resource according to the conditions you define.
result, err := checkRequest(request.Request, handler)
response := admission.AdmissionResponse{
UID: request.Request.UID,
Allowed: result,
}
if err != nil {
response.Result = &k8meta.Status{
Message: fmt.Sprintf("%v", err),
Reason: k8meta.StatusReasonUnauthorized,
}
}
outReview := admission.AdmissionReview{
TypeMeta: request.TypeMeta,
Request: request.Request,
Response: &response,
}
json, err := json.Marshal(outReview)
if err != nil {
http.Error(w, fmt.Sprintf("Error encoding response %v", err), http.StatusInternalServerError)
} else {
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(json); err != nil {
log.Printf("Error writing response %v", err)
http.Error(w, fmt.Sprintf("Error writing response: %v", err), http.StatusInternalServerError)
}
}
Building our project
As this is a standard go project, you can use a very simple Dockerfile to create the image. This image can be built by running docker build . –tag fdns/simple-admission:latest (You can change the tag to the one you like).
FROM golang:1.16.2 as builder
WORKDIR $GOPATH/src/github.com/fdns/simple-admission
COPY go.mod .
COPY go.sum .
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /go/bin/simple-admission
FROM scratch
COPY --from=builder /go/bin/simple-admission /go/bin/simple-admission
ENTRYPOINT ["/go/bin/simple-admission"]
The only thing left is uploading it to our cluster.
Uploading controller to a kubernetes cluster
Create TLS certificates
As the webhook require the use of HTTPS to work, we can create our own CA and certificate for the controller. The CA keys can be dropped as soon as we sign the client certificate, as the CA bundle is included in the ValidatingAdmissionWebhook object.
As the requests will come from a service object, you will want to define as altnames in the certificate all the variations to call the services. In the configuration, this will look as something like the following.
[alt_names]
DNS.1 = ${service}
DNS.2 = ${service}.${namespace}
DNS.3 = ${service}.${namespace}.svc
To stop copying so much code, you can find a simple script to generate the certificate at https://github.com/fdns/simple-admission/blob/master/generate_certs.sh, which we will call with the service name and namespace of our admission controller (For example, ./generate_certs.sh simple-admission default).
The generated certificates must be mounted as a secret, as we will need to mount them in our application (save the ca.pem file as we will need it later).
apiVersion: v1
kind: Secret
metadata:
creationTimestamp: null
name: admission-certs
namespace: default
data:
server-key.pem: $(cat certs/server-key.pem | base64 | tr -d '\n')
server.pem: $(cat certs/server.crt | base64 | tr -d '\n')
Creating the service and webhook
You can create the deployment and services the same way as any other deployment in your cluster. Here it is recommended to increase the replica count to increase the availability.
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: simple-admission
name: simple-admission
spec:
replicas: 1
selector:
matchLabels:
app: simple-admission
strategy: {}
template:
metadata:
labels:
app: simple-admission
spec:
containers:
- name: simple-admission
image: fdns/simple-admission:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8443
volumeMounts:
- name: admission-certs
mountPath: /certs
readOnly: true
volumes:
- name: admission-certs
secret:
secretName: admission-certs
---
apiVersion: v1
kind: Service
metadata:
creationTimestamp: null
labels:
app: simple-admission
name: simple-admission
spec:
ports:
- name: 443-8443
port: 443
protocol: TCP
targetPort: 8443
selector:
app: simple-admission
type: ClusterIP
Creating the ValidatingAdmissionWebhook
Finally, we will create the ValidatingAdmissionWebhook. We can define multiple webhooks, where in each one we must tell kubernetes the service, path and CA to send the request to the admission controller. For each one we can define the rules to filter the requests that are sent to our controller, where in this case we will filter for jobs resources in namespaces labeled with the name default (the namespace MUST be labeled in our example).
In case you want to audit your webook before applying it to your cluster, you can change the failurePolicy from Fail to Ignore
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: simple-admission.default.cluster.local
namespace: default
webhooks:
- name: simple-admission.default.cluster.local
clientConfig:
service:
name: simple-admission
namespace: default
path: "/validate"
caBundle: $(cat certs/ca.pem | base64 | tr -d '\n')
rules:
- apiGroups: ["batch"]
apiVersions: ["v1"]
resources: ["jobs"]
operations: ["CREATE"]
scope: "*"
namespaceSelector:
matchExpressions:
- key: name
operator: In
values: ["default"]
admissionReviewVersions: ["v1"]
sideEffects: None
failurePolicy: Fail
Testing our admission controller
To the newly applied admission controller, you can simply try to create a basic job running kubectl create job test –image busybox, which in our case will output the following message:
error: failed to create job: admission webhook "simple-admission.default.cluster.local" denied the request: activeDeadlineSeconds must be set
Conclusions
Creating an admission controller is not difficult, but making sure all the parameters to make your containers secure is a difficult task, as not all fields are generally known, and new fields must be taken into account when kubernetes release a new version.
When creating a new admission controller, you should try to target a single problem, like image verification or single fields of the resources like runtimeClass over your cluster. In case you need more complex rules, the use of the already available admission controllers is recommended, as you can define the rules in your own CRD and allow you to iterate faster (some of them have audit mode so you can check your cluster before enforcing a rule).