Skip to content

Commit

Permalink
Feature: Built-in basic auth (#18)
Browse files Browse the repository at this point in the history
* ❇️ initial basic auth support

* 🔧 only run the chart release on main pushes

* ✨ enable backend auth by default

* 🐛 use healthcheck for connection test

* ♻️ include chart changes in build/test workflow

* 🔧 add black to dev reqs
  • Loading branch information
mshade authored Sep 13, 2023
1 parent 12cfd92 commit 03fc79a
Show file tree
Hide file tree
Showing 18 changed files with 176 additions and 49 deletions.
1 change: 0 additions & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on:
push:
paths-ignore:
- 'k8s/**'
- 'chart/**'
- '.github/workflows/chart-testing.yaml'

env:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/chart-testing.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ name: Chart Testing and Release

on:
push:
branches:
- main
paths:
- 'chart/**'
- '.github/workflows/chart-testing.yaml'
Expand Down
32 changes: 24 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ The simple Kubernetes CronJob admin UI.

Kronic is in early alpha. It may eat your cronjobs, pods, or even your job.
Avoid exposing Kronic to untrusted parties or networks.
In a multi-tenant cluster, ensure a sensible network policy is in place to prevent access to the service from other namespaces.


## Screenshots
Expand Down Expand Up @@ -48,6 +47,23 @@ and Pods in its own namespace. Enabling this setting in the helm chart values
(`env.KRONIC_NAMESPACE_ONLY="true"`) will prevent creation of ClusterRole and
ClusterRolebinding, creating only a namespaced Role and RoleBinding.

### Authentication

Kronic supports HTTP Basic authentication to the backend. It is enabled by default when installed via the helm chart. If no password is specified, the default username is `kronic` and the password is generated randomly.
A username and password can be set via helm values under `auth.adminUsername` and `auth.adminPassword`, or you may create a Kubernetes secret for the deployment to reference.

To retrieve the randomly generated admin password:
```
kubectl --namespace <namespace> get secret <release-name> -ojsonpath="{.data.password}" | base64 -d
```

To create an admin password secret for use with Kronic:
```
kubectl --namespace <namespace> create secret generic custom-password --from-literal=password=<password>
## Tell the helm chart to use this secret:
helm --namespace <namespace> upgrade kronic kronic/kronic --set auth.existingSecretName=custom-password
```

## Deploying to K8S

Expand All @@ -56,21 +72,21 @@ By default the Kronic helm chart will provide only a `ClusterIP` service. See th
most notably the `ingress` section.

> **Warning**
> Avoid exposing Kronic publicly! The ingress configuration allows for basic authentication, but
> provides only minimal protection. Ensure you change `ingress.auth.password` from the default if enabled.
> Best practice would be to use a privately routed ingress class or other network-level protections.
> You may also provide your own basic auth secret using `ingress.auth.secretName`. See [Ingress docs](https://kubernetes.github.io/ingress-nginx/examples/auth/basic/) on creation.
> Avoid exposing Kronic publicly! The default configuration allows for basic authentication, but
> provides only minimal protection.
To install Kronic as `kronic` in its own namespace:

```
helm repo add kronic https://mshade.github.io/kronic/
helm repo update
# Optionally fetch and customize values file
# Optionally fetch, then customize values file
helm show values kronic/kronic > myvalues.yaml
helm install -n kronic --create-namespace kronic kronic/kronic
# See the NOTES output for accessing Kronic and retrieving the initial admin password
```

If no ingress is configured (see warning above!), expose Kronic via `kubectl port-forward` and access `localhost:8000` in your browser:
Expand Down Expand Up @@ -109,7 +125,7 @@ Kronic is a small Flask app built with:
- [x] Helm chart
- [x] Allow/Deny lists for namespaces
- [x] Support a namespaced install (no cluster-wide view)
- [ ] Built-in auth options
- [x] Built-in auth option
- [ ] NetworkPolicy in helm chart
- [ ] Timeline / Cron schedule interpreter or display
- [ ] YAML/Spec Validation on Edit page
Expand Down
31 changes: 31 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from flask import Flask, request, render_template, redirect
from flask_httpauth import HTTPBasicAuth
from werkzeug.security import check_password_hash

from functools import wraps
import yaml

Expand All @@ -18,6 +21,19 @@
)

app = Flask(__name__, static_url_path="", static_folder="static")
auth = HTTPBasicAuth()


@auth.verify_password
def verify_password(username, password):
# No users defined, so no auth enabled
if not config.USERS:
return True
else:
if username in config.USERS and check_password_hash(
config.USERS.get(username), password
):
return username


# A namespace filter decorator
Expand Down Expand Up @@ -59,6 +75,7 @@ def healthz():

@app.route("/")
@app.route("/namespaces/")
@auth.login_required
def index():
if config.NAMESPACE_ONLY:
return redirect(
Expand All @@ -77,6 +94,7 @@ def index():

@app.route("/namespaces/<namespace>")
@namespace_filter
@auth.login_required
def view_namespace(namespace):
cronjobs = get_cronjobs(namespace)
cronjobs_with_details = []
Expand All @@ -96,6 +114,7 @@ def view_namespace(namespace):

@app.route("/namespaces/<namespace>/cronjobs/<cronjob_name>", methods=["GET", "POST"])
@namespace_filter
@auth.login_required
def view_cronjob(namespace, cronjob_name):
if request.method == "POST":
edited_cronjob = yaml.safe_load(request.form["yaml"])
Expand Down Expand Up @@ -146,6 +165,7 @@ def view_cronjob(namespace, cronjob_name):


@app.route("/api/")
@auth.login_required
def api_index():
if config.NAMESPACE_ONLY:
return redirect(
Expand All @@ -160,13 +180,15 @@ def api_index():
@app.route("/api/namespaces/<namespace>/cronjobs")
@app.route("/api/namespaces/<namespace>")
@namespace_filter
@auth.login_required
def api_namespace(namespace):
cronjobs = get_cronjobs(namespace)
return cronjobs


@app.route("/api/namespaces/<namespace>/cronjobs/<cronjob_name>")
@namespace_filter
@auth.login_required
def api_get_cronjob(namespace, cronjob_name):
cronjob = get_cronjob(namespace, cronjob_name)
return cronjob
Expand All @@ -176,6 +198,7 @@ def api_get_cronjob(namespace, cronjob_name):
"/api/namespaces/<namespace>/cronjobs/<cronjob_name>/clone", methods=["POST"]
)
@namespace_filter
@auth.login_required
def api_clone_cronjob(namespace, cronjob_name):
cronjob_spec = get_cronjob(namespace, cronjob_name)
new_name = request.json["name"]
Expand All @@ -189,6 +212,7 @@ def api_clone_cronjob(namespace, cronjob_name):

@app.route("/api/namespaces/<namespace>/cronjobs/create", methods=["POST"])
@namespace_filter
@auth.login_required
def api_create_cronjob(namespace):
cronjob_spec = request.json["data"]
cronjob = update_cronjob(namespace, cronjob_spec)
Expand All @@ -199,6 +223,7 @@ def api_create_cronjob(namespace):
"/api/namespaces/<namespace>/cronjobs/<cronjob_name>/delete", methods=["POST"]
)
@namespace_filter
@auth.login_required
def api_delete_cronjob(namespace, cronjob_name):
deleted = delete_cronjob(namespace, cronjob_name)
return deleted
Expand All @@ -209,6 +234,7 @@ def api_delete_cronjob(namespace, cronjob_name):
methods=["GET", "POST"],
)
@namespace_filter
@auth.login_required
def api_toggle_cronjob_suspend(namespace, cronjob_name):
if request.method == "GET":
"""Return the suspended status of the <cronjob_name>"""
Expand All @@ -224,6 +250,7 @@ def api_toggle_cronjob_suspend(namespace, cronjob_name):
"/api/namespaces/<namespace>/cronjobs/<cronjob_name>/trigger", methods=["POST"]
)
@namespace_filter
@auth.login_required
def api_trigger_cronjob(namespace, cronjob_name):
"""Manually trigger a job from <cronjob_name>"""
cronjob = trigger_cronjob(namespace, cronjob_name)
Expand All @@ -236,27 +263,31 @@ def api_trigger_cronjob(namespace, cronjob_name):

@app.route("/api/namespaces/<namespace>/cronjobs/<cronjob_name>/getJobs")
@namespace_filter
@auth.login_required
def api_get_jobs(namespace, cronjob_name):
jobs = get_jobs_and_pods(namespace, cronjob_name)
return jobs


@app.route("/api/namespaces/<namespace>/pods")
@namespace_filter
@auth.login_required
def api_get_pods(namespace):
pods = get_pods(namespace)
return pods


@app.route("/api/namespaces/<namespace>/pods/<pod_name>/logs")
@namespace_filter
@auth.login_required
def api_get_pod_logs(namespace, pod_name):
logs = get_pod_logs(namespace, pod_name)
return logs


@app.route("/api/namespaces/<namespace>/jobs/<job_name>/delete", methods=["POST"])
@namespace_filter
@auth.login_required
def api_delete_job(namespace, job_name):
deleted = delete_job(namespace, job_name)
return deleted
51 changes: 40 additions & 11 deletions chart/kronic/README.md.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,62 @@

Kronic is in early alpha. It may eat your cronjobs, pods, or even your job.
Avoid exposing Kronic to untrusted parties or networks.
In a multi-tenant cluster, ensure a sensible network policy is in place to prevent access to the service from other namespaces.


By default the Kronic helm chart will provide only a `ClusterIP` service. See the [values.yaml](./chart/kronic/values.yaml) for settings,
most notably the `ingress` section.
most notably the `ingress` section.


> **Warning**
> Avoid exposing Kronic publicly! The ingress configuration allows for basic authentication, but
> provides only minimal protection. Ensure you change `ingress.auth.password` from the default if enabled.
> Best practice would be to use a privately routed ingress class or other network-level protections.
> You may also provide your own basic auth secret using `ingress.auth.secretName`. See [Ingress docs](https://kubernetes.github.io/ingress-nginx/examples/auth/basic/) on creation.
## Configuration

Kronic can be limited to a list of namespaces. Specify as a comma separated list in the `KRONIC_ALLOW_NAMESPACES` environment variable.
The helm chart exposes this option. Example: `env.KRONIC_ALLOW_NAMESPACES='qa,test,dev'`

Kronic also supports a namespaced installation. The `KRONIC_NAMESPACE_ONLY`
environment variable will limit Kronic to interacting only with CronJobs, Jobs
and Pods in its own namespace. Enabling this setting in the helm chart values
(`env.KRONIC_NAMESPACE_ONLY="true"`) will prevent the creation of ClusterRole and
ClusterRolebinding, using only a namespaced Role and RoleBinding.

### Authentication

Kronic supports HTTP Basic authentication to the backend. It is enabled by default when installed via the helm chart. If no password is specified, the default username is `kronic` and the password is generated randomly.
A username and password can be set via helm values under `auth.adminUsername` and `auth.adminPassword`, or you may create a Kubernetes secret for the deployment to reference.

To retrieve the randomly generated admin password:
```
kubectl --namespace <namespace> get secret <release-name> -ojsonpath="{.data.password}" | base64 -d
```

To create an admin password secret for use with Kronic:
```
kubectl --namespace <namespace> create secret generic custom-password --from-literal=password=<password>

## Tell the helm chart to use this secret:
helm --namespace <namespace> upgrade kronic kronic/kronic --set auth.existingSecretName=custom-password
```

## Installation

A helm chart is available at [./chart/kronic](./chart/kronic/).
By default the Kronic helm chart will provide only a `ClusterIP` service. See the [values.yaml](./chart/kronic/values.yaml) for settings,
most notably the `ingress` section.

> **Warning**
> Avoid exposing Kronic publicly! The default configuration allows for basic authentication, but
> provides only minimal protection.

To install Kronic as `kronic` in its own namespace:

```
helm repo add kronic https://mshade.github.io/kronic/
helm repo update

# Optionally fetch and customize values file
# Optionally fetch, then customize values file
helm show values kronic/kronic > myvalues.yaml

helm install -n kronic --create-namespace kronic kronic/kronic -f myvalues.yaml
helm install -n kronic --create-namespace kronic kronic/kronic

# See the NOTES output for accessing Kronic and retrieving the initial admin password
```

If no ingress is configured (see warning above!), expose Kronic via `kubectl port-forward` and access `localhost:8000` in your browser:
Expand All @@ -41,7 +71,6 @@ If no ingress is configured (see warning above!), expose Kronic via `kubectl por
kubectl -n kronic port-forward deployment/kronic 8000:8000
```


{{ template "chart.requirementsSection" . }}

{{ template "chart.valuesSection" . }}
Expand Down
6 changes: 2 additions & 4 deletions chart/kronic/ci/custom-auth-values.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
ingress:
auth:
enabled: true
auth:
enabled: true
secretName: custom-basic-auth
adminPassword: "testing"

env:
KRONIC_NAMESPACE_ONLY: "true"
6 changes: 4 additions & 2 deletions chart/kronic/ci/ingress-values.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
ingress:
enabled: true
auth:
enabled: true
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
Expand All @@ -15,3 +13,7 @@ ingress:
# - secretName: chart-example-tls
# hosts:
# - chart-example.local

auth:
enabled: true
adminPassword: "testing"
16 changes: 12 additions & 4 deletions chart/kronic/templates/NOTES.txt
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
Thanks for using Kronic!
1. Get the application URL by running these commands:

1. Access the application:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
Visit http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "kronic.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
Visit http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "kronic.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "kronic.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
kubectl --namespace {{ .Release.Namespace }} port-forward deployment/{{ include "kronic.fullname" . }} 8000:8000
echo "Visit http://127.0.0.1:8000 to use Kronic"
Visit http://127.0.0.1:8000
{{- end }}
{{- if .Values.auth.enabled }}

2. Retrieve your admin password with:
kubectl --namespace {{ .Release.Namespace }} get secret {{ .Values.auth.existingSecretName | default (include "kronic.fullname" .) }} -ojsonpath="{.data.password}" | base64 -d

Login with the username '{{ .Values.auth.adminUsername }}' and the password obtained from the command above.
{{- end }}

Submit issues or feedback at: https://github.com/mshade/kronic/issues
18 changes: 18 additions & 0 deletions chart/kronic/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,21 @@ Create the name of the service account to use
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

{{- define "kronic.adminPassword" -}}
{{- if empty .Values.auth.adminPassword }}
{{/*
User can provide pre-existing secret
*/}}
{{- $secretObj := (lookup "v1" "Secret" .Release.Namespace .Values.auth.existingSecretName ) | default dict }}
{{- $secretData := (get $secretObj "data") | default dict }}
{{- $adminPass := (get $secretData .Values.auth.existingSecretName ) | default (randAlphaNum 16) }}
{{- if empty $adminPass }}
{{- $adminPass := "dry-run" }}
{{- end }}
{{- printf "%s" $adminPass }}
{{- else }}
{{- $adminPass := .Values.auth.adminPassword }}
{{- printf "%s" $adminPass }}
{{- end}}
{{- end }}
Loading

0 comments on commit 03fc79a

Please sign in to comment.