diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml index 706f90f..79a4472 100644 --- a/.github/workflows/build-app.yml +++ b/.github/workflows/build-app.yml @@ -15,7 +15,7 @@ on: workflow_dispatch: env: - GO_VERSION: '1.19' + GO_VERSION: '1.20' REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 1508fd5..2e499a7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ *.so *.dylib +.go/ + # Docker image files *.tar.gz *.tar.xz @@ -23,4 +25,7 @@ .errors/ # Go tests -coverage.out \ No newline at end of file +coverage.out + +# Secret env vars +config/secrets diff --git a/Dockerfile b/Dockerfile index 336d852..c2c82fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,44 +1,44 @@ -# --- build stage -FROM golang:1.20 AS builder - -WORKDIR /builder/app -COPY go/src/app/ . -COPY go/src/tests/ /builder/tests/ -COPY go.mod . - -RUN go get -d -v . &&\ - go build -v -o /go/bin/app . - -RUN go test -v -coverprofile=coverage.out -covermode=atomic -# go tool cover -func=coverage.out - - -# --- Publish test coverage results -FROM scratch as test-coverage -COPY --from=builder /builder/app/coverage.out . - - -# --- Production image -FROM ubuntu:latest -LABEL Name=kapparmor -LABEL Author="Affinito Alessandro" - -WORKDIR /app - -RUN apt-get update &&\ - apt-get upgrade -y &&\ - apt-get install --no-install-recommends --yes apparmor apparmor-utils &&\ - rm -rf /var/lib/apt/lists/* &&\ - mkdir --parent --verbose /etc/apparmor.d/custom - -COPY --from=builder /go/bin/app /app/ -COPY ./charts/kapparmor/profiles /app/profiles/ - -ARG PROFILES_DIR -ARG POLL_TIME - -ENV PROFILES_DIR=$PROFILES_DIR -ENV POLL_TIME=$POLL_TIME - -USER root -CMD ./app \ No newline at end of file +# --- build stage +FROM golang:1.20 AS builder + +WORKDIR /builder/app +COPY go/src/app/ . +COPY go/src/tests/ /builder/tests/ +COPY go.mod . + +RUN go get -d -v . &&\ + go build -v -o /go/bin/app . + +RUN go test -v -coverprofile=coverage.out -covermode=atomic ./... +# go tool cover -func=coverage.out + + +# --- Publish test coverage results +FROM scratch as test-coverage +COPY --from=builder /builder/app/coverage.out . + + +# --- Production image +FROM ubuntu:latest +LABEL Name=kapparmor +LABEL Author="Affinito Alessandro" + +WORKDIR /app + +RUN apt-get update &&\ + apt-get upgrade -y &&\ + apt-get install --no-install-recommends --yes apparmor apparmor-utils &&\ + rm -rf /var/lib/apt/lists/* &&\ + mkdir --parent --verbose /etc/apparmor.d/custom + +COPY --from=builder /go/bin/app /app/ +COPY ./charts/kapparmor/profiles /app/profiles/ + +ARG PROFILES_DIR +ARG POLL_TIME + +ENV PROFILES_DIR=$PROFILES_DIR +ENV POLL_TIME=$POLL_TIME + +USER root +ENTRYPOINT ["./app"] \ No newline at end of file diff --git a/README.md b/README.md index 4875656..7e3d746 100644 --- a/README.md +++ b/README.md @@ -46,15 +46,19 @@ helm upgrade kapparmor --install --atomic --timeout 120s --debug --set image.tag ``` ## Known limitations -- Profiles names are checked on the first line, so if there is some include before that would fail -- Profile names have to start with 'custom.' and to be equal as the filename containing it -- There could be issues if you start the daemonsets on "dirty" nodes, where some old custom profiles were left after stopping or uninstalling Kapparmor. E.g: you stop the pods and then redeploy the app with an empty profiles configmap without removing the previous custom profiles: Kapparmor will try to remove the old profiles but it could fail since there is no definition of them anymore. +- Constraint: Profiles are validated on the "`profile`" keyword presence before of a opening curly bracket `{`. + It must be a [unattached profiles](https://documentation.suse.com/sles/15-SP1/html/SLES-all/cha-apparmor-profiles.html#sec-apparmor-profiles-types-unattached). +- Profile names have to start with 'custom.' and to be equal as the filename containing it. +- There could be issues if you start the daemonsets on "dirty" nodes, where some old custom profiles were left after stopping or uninstalling Kapparmor. + E.G: By default if you delete a pod all the profiles should be automatically deleted from that node, but the app crashes during the process. + - Not a limitation relative to this project, but if you deny write access in the /bin folder of a privileged container it could not be deleted by Kubernetes even after 'kubectl delete'. The command will succeed but the pod will stay in Terminating state. ## ToDo: -- Intercept Term signal and uninstall profiles before the Helm chart deletion completes. -- Implement the [controller-runtime](https://pkg.go.dev/sigs.k8s.io/controller-runtime#section-readme) design pattern through [Kubebuilder](https://book.kubebuilder.io/quick-start.html). - +- [X] Intercept Term signal and uninstall profiles before the Helm chart deletion completes. +- ⚠️ Implement the [controller-runtime](https://pkg.go.dev/sigs.k8s.io/controller-runtime#section-readme) design pattern through [Kubebuilder](https://book.kubebuilder.io/quick-start.html). +- 😁 Find funnier quotes for app starting and ending message (David Zucker, Monty Python, Woody Allen...). +- 🌱 Make the ticker loop thread safe: skip running a new loop if previous run is still ongoing. ## Testing [There is a whole project meant to be a demo for this one](https://github.com/tuxerrante/kapparmor-demo), have fun. diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index e69de29..0000000 diff --git a/build/build-and-deploy.sh b/build/build-and-deploy.sh new file mode 100644 index 0000000..da604c3 --- /dev/null +++ b/build/build-and-deploy.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +. ./build/build-app.sh +. ./config/secrets + +echo $GHCR_TOKEN | docker login -u "$(git config user.email)" --password-stdin ghcr.io +docker push ghcr.io/tuxerrante/kapparmor:${APP_VERSION}_dev + +# Install the chart from the local directory +helm upgrade kapparmor --install \ + --atomic \ + --debug \ + --set image.tag=${APP_VERSION}_dev \ + --set image.pullPolicy=Always \ + --dry-run \ + charts/kapparmor + +echo +echo "> Is the previous result the expected one?" +echo "> Current K8S context:" "$(kubectl config current-context)" +read -r -p "> Are you sure? [Y/n] " response +if [[ "$response" =~ ^([yY][eE][sS]|[yY])$ ]]; then + helm upgrade kapparmor --install \ + --atomic \ + --timeout 120s \ + --debug \ + --set image.tag=${APP_VERSION}_dev \ + --set image.pullPolicy=Always \ + charts/kapparmor +else + echo " Bye." + echo +fi diff --git a/build/build-app.sh b/build/build-app.sh new file mode 100644 index 0000000..37e2f6f --- /dev/null +++ b/build/build-app.sh @@ -0,0 +1,43 @@ +#!/bin/bash +source ./config/config + +# --- Validate App and Chart version +YML_CHART_VERSION="$(grep "version: [\"0-9\.]\+" charts/kapparmor/Chart.yaml |cut -d'"' -f2)" +YML_APP_VERSION="$(grep "appVersion: [\"0-9\.]\+" charts/kapparmor/Chart.yaml |cut -d'"' -f2)" + +if [[ $APP_VERSION != $YML_APP_VERSION ]]; then + echo "The APP version declared in the Chart is different from the one in the config!" + exit 1 +elif [[ $CHART_VERSION != $YML_CHART_VERSION ]]; then + echo "The APP version declared in the Chart is different from the one in the config!" + exit 1 +fi + +# Clean old images +echo "> Removing old and dangling old images..." +docker rmi "$(docker images --filter "reference=ghcr.io/tuxerrante/kapparmor" -q --no-trunc )" + +# go build -o ./.go/bin ./... +# go test -v -coverprofile=coverage.out -covermode=atomic ./go/src/app/... +if [[ ! -f ".go/bin/golangci-lint" ]]; then + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b ./.go/bin +fi +echo "> Linting..." +./.go/bin/golangci-lint run + +echo "> Scanning for suspicious constructs..." +go vet go/... + +echo "> Creating test output..." +docker build --target test-coverage --tag "ghcr.io/tuxerrante/kapparmor:${APP_VERSION}_dev" . + +#### To run it look into docs/testing.md +echo "> Building container image..." +docker build --tag "ghcr.io/tuxerrante/kapparmor:${APP_VERSION}_dev" \ + --no-cache \ + --build-arg POLL_TIME=30 \ + --build-arg PROFILES_DIR=/app/profiles \ + -f Dockerfile \ + . + +# docker run --rm -it --privileged --mount type=bind,source='/sys/kernel/security',target='/sys/kernel/security' --mount type=bind,source='/etc',target='/etc' --name kapparmor ghcr.io/tuxerrante/kapparmor:${APP_VERSION}_dev diff --git a/charts/kapparmor/Chart.yaml b/charts/kapparmor/Chart.yaml index b884942..5499b0e 100644 --- a/charts/kapparmor/Chart.yaml +++ b/charts/kapparmor/Chart.yaml @@ -5,8 +5,9 @@ type: application home: https://artifacthub.io kubeVersion: ">= 1.23.0-0" -version: "0.1.2" -appVersion: "0.1.2" +# Respect spaces and double quotes since this will be validated by the build-app script. +version: "0.1.5" +appVersion: "0.1.5" keywords: - kubernetes diff --git a/charts/kapparmor/templates/cm-profiles.yaml b/charts/kapparmor/templates/cm-profiles.yaml index 85861c8..977f061 100644 --- a/charts/kapparmor/templates/cm-profiles.yaml +++ b/charts/kapparmor/templates/cm-profiles.yaml @@ -3,5 +3,10 @@ kind: ConfigMap metadata: name: kapparmor-profiles namespace: {{ .Release.Namespace }} + labels: + {{- include "kapparmor.labels" . | nindent 4 }} + {{- with .Values.app.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} data: {{ (.Files.Glob "profiles/*").AsConfig | indent 2 }} \ No newline at end of file diff --git a/charts/kapparmor/templates/cm-settings.yaml b/charts/kapparmor/templates/cm-settings.yaml index ec15981..1e877db 100644 --- a/charts/kapparmor/templates/cm-settings.yaml +++ b/charts/kapparmor/templates/cm-settings.yaml @@ -3,6 +3,11 @@ kind: ConfigMap metadata: name: kapparmor-settings namespace: {{ .Release.Namespace }} + labels: + {{- include "kapparmor.labels" . | nindent 4 }} + {{- with .Values.app.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} data: PROFILES_DIR: "{{ .Values.app.profiles_dir }}" POLL_TIME: "{{ .Values.app.poll_time }}" \ No newline at end of file diff --git a/charts/kapparmor/templates/daemonset.yaml b/charts/kapparmor/templates/daemonset.yaml index ec49aac..56836bd 100644 --- a/charts/kapparmor/templates/daemonset.yaml +++ b/charts/kapparmor/templates/daemonset.yaml @@ -4,7 +4,10 @@ metadata: name: {{ include "kapparmor.fullname" . }} namespace: {{ .Release.Namespace }} labels: - {{- include "kapparmor.labels" . | nindent 4 }} + {{- include "kapparmor.labels" . | nindent 4 }} + {{- with .Values.daemonset.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} spec: selector: matchLabels: @@ -16,9 +19,11 @@ spec: {{- with .Values.podAnnotations }} {{- toYaml . | nindent 8 }} {{- end }} - labels: {{- include "kapparmor.selectorLabels" . | nindent 8 }} + {{- with .Values.app.labels }} + {{- toYaml . | nindent 8 }} + {{- end }} spec: {{- with .Values.imagePullSecrets }} diff --git a/charts/kapparmor/templates/service.yaml b/charts/kapparmor/templates/service.yaml index 4dd8f27..bbb7da0 100644 --- a/charts/kapparmor/templates/service.yaml +++ b/charts/kapparmor/templates/service.yaml @@ -5,6 +5,9 @@ metadata: namespace: {{ .Release.Namespace }} labels: {{- include "kapparmor.labels" . | nindent 4 }} + {{- with .Values.app.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} spec: type: {{ .Values.service.type }} ports: diff --git a/charts/kapparmor/values.yaml b/charts/kapparmor/values.yaml index c120db3..322268b 100644 --- a/charts/kapparmor/values.yaml +++ b/charts/kapparmor/values.yaml @@ -12,6 +12,8 @@ fullnameOverride: "" app: profiles_dir: "/app/profiles" poll_time: 60 + labels: +# costgroup: "test" serviceAccount: # Specifies whether a service account should be created @@ -22,6 +24,9 @@ serviceAccount: # If not set and create is true, a name is generated using the fullname template name: "" +daemonset: + labels: {} + podAnnotations: {} podSecurityContext: {} diff --git a/config/config b/config/config new file mode 100644 index 0000000..bdb6896 --- /dev/null +++ b/config/config @@ -0,0 +1,2 @@ +APP_VERSION=0.1.5 +CHART_VERSION=0.1.5 \ No newline at end of file diff --git a/cr.yaml b/cr.yaml index 43f404c..0d63514 100644 --- a/cr.yaml +++ b/cr.yaml @@ -5,8 +5,8 @@ chart-dirs: - ./charts chart-repos: - kapparmor=https://tuxerrante.github.io/kapparmor/ -helm-extra-args: --timeout 600s +helm-extra-args: --timeout 60s validate-maintainers: true generate-release-notes: true make-release-latest: false -release-notes-file: CHANGELOG.md \ No newline at end of file +release-notes-file: CHANGELOG.md diff --git a/ct.yaml b/ct.yaml index 7c8e870..27dcc85 100644 --- a/ct.yaml +++ b/ct.yaml @@ -3,5 +3,5 @@ chart-dirs: - ./charts chart-repos: - kapparmor=https://tuxerrante.github.io/kapparmor/ -helm-extra-args: --timeout 600s +helm-extra-args: --timeout 60s validate-maintainers: true diff --git a/docs/testing.md b/docs/testing.md index 9117c9c..47c4c3e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -24,8 +24,13 @@ docker run -it --network host --workdir=/data --volume ~/.kube/config:/root/.kub /bin/sh -c "git config --global --add safe.directory /data; ct lint --print-config --charts ./charts/kapparmor" # Replace here a commit id being part of an image tag -export GITHUB_SHA="sha-93d0dc4c597a8ae8a9febe1d68e674daf1fa919a" -helm install --dry-run --atomic --generate-name --timeout 30s --debug --set image.tag=$GITHUB_SHA charts/kapparmor/ +export IMAGE_TAG="0.1.4_dev" +helm upgrade kapparmor --install --dry-run \ + --atomic \ + --timeout 30s \ + --debug \ + --namespace test \ + --set image.tag=$IMAGE_TAG charts/kapparmor/ ``` diff --git a/go/src/app/filesystemOperations.go b/go/src/app/filesystemOperations.go index 6b6c4ac..05a98ee 100644 --- a/go/src/app/filesystemOperations.go +++ b/go/src/app/filesystemOperations.go @@ -168,6 +168,16 @@ func IsProfileNameCorrect(directory, filename string) error { } scanner := bufio.NewScanner(fileReader) + // Validate the syntax + // the first index of a curly bracket should be greater than the first occurrence of "profile" + fileBytes, err := os.ReadFile(path.Join(directory, filename)) + checkFatal(err) + profileIndex := bytes.Index(fileBytes, []byte("profile")) + curlyBracketIndex := bytes.Index(fileBytes, []byte("{")) + if curlyBracketIndex < 0 || curlyBracketIndex < profileIndex { + return errors.New("couldn't find a { after 'profile' keyword") + } + // Search for line starting with 'profile' word for scanner.Scan() { fileLine := scanner.Text() diff --git a/go/src/app/main.go b/go/src/app/main.go index 1697ada..56e82eb 100644 --- a/go/src/app/main.go +++ b/go/src/app/main.go @@ -3,13 +3,16 @@ package main import ( "bufio" "bytes" + "context" "fmt" "log" "os" "os/exec" + "os/signal" "path" "sort" "strings" + "syscall" "time" ) @@ -30,16 +33,38 @@ func main() { POLL_TIME = preFlightChecks() + keepItRunning := make(chan struct{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + log.Printf("> Polling directory %s every %d seconds.\n", CONFIGMAP_PATH, POLL_TIME) + go pollProfiles(POLL_TIME, ctx, keepItRunning) - pollProfiles(POLL_TIME) -} + // Manage OS signals for graceful shutdown + go func() { + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT, os.Interrupt) + <-signals + log.Print("> Received stop signal, terminating..") -// Profiles will probably change content while keeping the same name, so a digest comparison -// can be useful if we don't want to load everything every time. -func pollProfiles(delay int) { + // Delete all loaded profiles + err := unloadAllProfiles() + checkFatal(err) - ticker := time.NewTicker(time.Duration(delay) * time.Second) + // Stop polling new profiles + cancel() + log.Print("> The Eagle has landed.") + }() + + <-keepItRunning +} + +// Every pollTime seconds it will read the mounted volume for profiles, +// it will call loadNewProfiles() then to check if they are new ones or not. +// Executed as go-routine it will run forever until a cancel() is called on the given context. +func pollProfiles(pollTime int, ctx context.Context, keepItRunning chan struct{}) { + log.Print("> Polling started.") + ticker := time.NewTicker(time.Duration(pollTime) * time.Second) pollNow := func() { newProfiles, err := loadNewProfiles() if err != nil { @@ -47,10 +72,14 @@ func pollProfiles(delay int) { } } - pollNow() - - for range ticker.C { - pollNow() + for { + select { + case <-ctx.Done(): + keepItRunning <- struct{}{} + return + case <-ticker.C: + pollNow() + } } } @@ -99,7 +128,7 @@ func loadNewProfiles() ([]string, error) { // If the profile is exactly the same skip the apply log.Printf("Checking %s profile..", path.Join(CONFIGMAP_PATH, newProfileName)) - contentIsTheSame, err := HasTheSameContent(nil, filePath1, path.Join(CONFIGMAP_PATH, newProfileName)) + contentIsTheSame, err := HasTheSameContent(nil, filePath1, path.Join(ETC_APPARMORD, newProfileName)) if err != nil { // Error in checking the content of "/app/profiles/custom.deny-write-outside-app" VS "custom.deny-write-outside-app" log.Printf(">> Error in checking the content of %q VS %q\n", filePath1, newProfileName) @@ -122,7 +151,7 @@ func loadNewProfiles() ([]string, error) { } // Execute apparmor_parser --replace --verbose filteredNewProfiles - log.Println("============================================================") + printLogSeparator() log.Println("> Apparmor REPLACE and apply new profiles..") for _, profilePath := range newProfilesToApply { err := loadProfile(profilePath) @@ -133,7 +162,7 @@ func loadNewProfiles() ([]string, error) { // Execute apparmor_parser --remove obsoleteProfilePath if len(loadedProfilesToUnload) > 0 { - log.Println("============================================================") + printLogSeparator() log.Println("> AppArmor REMOVE orphans profiles..") for _, profileFileName := range loadedProfilesToUnload { err := unloadProfile(profileFileName) @@ -144,6 +173,7 @@ func loadNewProfiles() ([]string, error) { } log.Println("> Done!\n> Waiting next poll..") + printLogSeparator() return newProfilesToApply, nil } @@ -205,15 +235,28 @@ func parseProfileName(profileLine string) string { func loadProfile(profilePath string) error { err := execApparmor("--verbose", "--replace", profilePath) - if err != nil { - log.Fatal(err) - } + checkFatal(err) // Copy the profile definition in the apparmor configuration standard directory log.Printf("Copying profile in %s", ETC_APPARMORD) return CopyFile(profilePath, ETC_APPARMORD) } +// Remove all custom profiles from the kernel, reading from ETC_APPARMORD folder +func unloadAllProfiles() error { + dirEntries, err := os.ReadDir(ETC_APPARMORD) + checkFatal(err) + + for _, entry := range dirEntries { + if !entry.IsDir() && entry.Type().IsRegular() { + err := unloadProfile(entry.Name()) + checkFatal(err) + } + } + return nil +} + +// Remove an apparmor profile from the kernel func unloadProfile(fileName string) error { filePath := path.Join(ETC_APPARMORD, fileName) @@ -240,3 +283,14 @@ func execApparmor(args ...string) error { return nil } + +func checkFatal(err error) { + if err != nil { + log.Fatal(err) + } +} + +// Useless line separator to simplify logs reading. +func printLogSeparator() { + log.Println("============================================================") +}