Skip to content

Commit

Permalink
feat: add verified request support for unidling requests
Browse files Browse the repository at this point in the history
  • Loading branch information
shreddedbacon committed Dec 15, 2023
1 parent e71e12e commit b2f920b
Show file tree
Hide file tree
Showing 9 changed files with 200 additions and 27 deletions.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ To force scale a namespace, you can label the namespace using `idling.amazee.io/
### Unidle
To unidle a namespace, you can label the namespace using `idling.amazee.io/unidle=true`. This will cause the environment to be scaled back up to its previous state.

### Idled
A label `idling.amazee.io/idled` is set that will be true or false depending on if the environment is idled. This ideally should not be modified as Aergia will update it as required.

### Namespace Idling Overrides
If you want to change a namespaces interval check times outside of the globally applied intervals, the following annotations can be added to the namespace
* `idling.amazee.io/prometheus-interval` - set this to the time interval for prometheus checks, the format must be in [30m|4h|1h30m](https://pkg.go.dev/time#ParseDuration) notation
Expand All @@ -44,6 +47,18 @@ There are also annotations that can be added to the namespace, or individual `Ki
* `idling.amazee.io/allowed-agents` - a comma separated list of user agents or regex patterns to allow.
* `idling.amazee.io/blocked-agents` - a comma separated list of user agents or regex patterns to block.

### Verify Unidling Requests
It is possible to start Aergia in a mode where it will require unidling requests to be verified. The way this works is by using HMAC and passing the signed version of the requested namespace back to the user when the initial request to unidle the environment is received. When a client loads this page, it will execute a javascript query back to the requested ingress which is then verified by Aergia. If verification suceeds, it proceeds to unidle the environment. This functionality can be useful to prevent bots and other systems that don't have the ability to execute javascript from unidling environments uncessarily. The signed namespace value will only work for the requested namespace.

To enable this functionality, set the following:
- `--verified-unidling=true` or envvar `VERIFIED_UNIDLING=true`
- `--verify-secret=use-your-own-secret` or envvar `VERIFY_SECRET=use-your-own-secret`

If the verification featuer is enabled, and you need to unidle environments using tools that can't execute javascript, then it is possible to allow a namespace to override the feature by adding the following annotation to the namespace. Using the other allow/blocking mechanisms can then be used to restrict how the environment can unidle if required.
* `idling.amazee.io/disable-hmac-verification=true` - set this to disable the hmac verification on a namespace if Aergia has unidling request verification turned on.

If you're using custom template overrides and enable this functionality, you will need to extend your `unidle.html` template with the additional changes to allow it to to perform the call back function or else environments will never unidle. See the bundled `unidle.html` file to see how this may differ from your custom templates.

## Change the default templates

By using the environment variable `ERROR_FILES_PATH`, and pointing to a location that contains the three templates `error.html`, `forced.html`, and `unidle.html`, you can change what is shown to the end user.
Expand Down
2 changes: 1 addition & 1 deletion controllers/idling_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func (r *IdlingReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr

if val, ok := namespace.ObjectMeta.Labels["idling.amazee.io/unidle"]; ok && val == "true" {
opLog.Info(fmt.Sprintf("Unidling environment %s", namespace.Name))
r.Unidler.UnIdle(ctx, namespace.Name, opLog)
r.Unidler.Unidle(ctx, &namespace, opLog)
nsMergePatch, _ := json.Marshal(map[string]interface{}{
"metadata": map[string]interface{}{
"labels": map[string]*string{
Expand Down
16 changes: 16 additions & 0 deletions handlers/idler/service-kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ func (h *Idler) patchIngress(ctx context.Context, opLog logr.Logger, namespace c
opLog.Error(err, fmt.Sprintf("Error getting ingress"))
return fmt.Errorf("Error getting ingress")
}
patched := false
for _, ingress := range ingressList.Items {
if !h.DryRun {
ingressCopy := ingress.DeepCopy()
Expand All @@ -260,10 +261,25 @@ func (h *Idler) patchIngress(ctx context.Context, opLog logr.Logger, namespace c
return fmt.Errorf(fmt.Sprintf("Error patching ingress %s", ingress.ObjectMeta.Name))
}
opLog.Info(fmt.Sprintf("Ingress %s patched", ingress.ObjectMeta.Name))
patched = true
} else {
opLog.Info(fmt.Sprintf("Ingress %s would be patched", ingress.ObjectMeta.Name))
}
}
if patched {
// update the namespace to indicate it is not idled
namespaceCopy := namespace.DeepCopy()
mergePatch, _ := json.Marshal(map[string]interface{}{
"metadata": map[string]interface{}{
"labels": map[string]string{
"idling.amazee.io/idled": "false",
},
},
})
if err := h.Client.Patch(ctx, namespaceCopy, client.RawPatch(types.MergePatchType, mergePatch)); err != nil {
return fmt.Errorf(fmt.Sprintf("Error patching namespace %s", namespace.Name))
}
}
}
return nil
}
51 changes: 36 additions & 15 deletions handlers/unidler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,9 @@ func (h *Unidler) ingressHandler(path string) func(http.ResponseWriter, *http.Re
w.Header().Set(ServicePort, r.Header.Get(ServicePort))
w.Header().Set(RequestID, r.Header.Get(RequestID))
}

format := r.Header.Get(FormatHeader)
if format == "" {
format = "text/html"
// log.Printf("format not specified. Using %v", format)
}

cext, err := mime.ExtensionsByType(format)
Expand All @@ -59,13 +57,11 @@ func (h *Unidler) ingressHandler(path string) func(http.ResponseWriter, *http.Re
code, err := strconv.Atoi(errCode)
if err != nil {
code = 404
// log.Printf("unexpected error reading return code: %v. Using %v", err, code)
}
w.WriteHeader(code)
ns := r.Header.Get(Namespace)
ingressName := r.Header.Get(IngressName)
// @TODO: check for code 503 specifically, or just any request that has the namespace in it will be "unidled" if a request comes in for
// that ingress and the
// check if the namespace exists so we know this is somewhat legitimate request
if ns != "" {
namespace := &corev1.Namespace{}
if err := h.Client.Get(ctx, types.NamespacedName{
Expand All @@ -74,13 +70,15 @@ func (h *Unidler) ingressHandler(path string) func(http.ResponseWriter, *http.Re
opLog.Info(fmt.Sprintf("unable to get any namespaces: %v", err))
return
}
// if hmac verification is enabled, perform the verification of the request
signedNamespace, verfied := h.verifyRequest(r, namespace)
ingress := &networkv1.Ingress{}
if err := h.Client.Get(ctx, types.NamespacedName{
Namespace: ns,
Name: ingressName,
}, ingress); err != nil {
opLog.Info(fmt.Sprintf("Unable to get the ingress %s in %s", ingressName, ns))
h.genericError(w, r, opLog, ext, format, path, 400)
h.genericError(w, r, opLog, ext, format, path, "", 400)
h.setMetrics(r, start)
return
}
Expand All @@ -104,16 +102,20 @@ func (h *Unidler) ingressHandler(path string) func(http.ResponseWriter, *http.Re
} else {
// only unidle environments that aren't force scaled
// actually do the unidling here, lock to prevent multiple unidle operations from running
_, ok := h.Locks.Load(ns)
if !ok {
_, _ = h.Locks.LoadOrStore(ns, ns)
go h.UnIdle(ctx, ns, opLog)
if verfied {
w.Header().Set("X-Aergia-Allowed", "true")
_, ok := h.Locks.Load(ns)
if !ok {
_, _ = h.Locks.LoadOrStore(ns, ns)
go h.Unidle(ctx, namespace, opLog)
}
} else {
w.Header().Set("X-Aergia-Denied", "true")
}
}
if h.Debug == true {
opLog.Info(fmt.Sprintf("Serving custom error response for code %v and format %v from file %v", code, format, file))
}
w.Header().Set("X-Aergia-Allowed", "true")
// then return the unidle template to the user
tmpl := template.Must(template.ParseFiles(file))
tmpl.ExecuteTemplate(w, "base", pageData{
Expand All @@ -128,21 +130,22 @@ func (h *Unidler) ingressHandler(path string) func(http.ResponseWriter, *http.Re
ServicePort: r.Header.Get(ServicePort),
RequestID: r.Header.Get(RequestID),
RefreshInterval: h.RefreshInterval,
Verifier: signedNamespace,
})
} else {
// respond with 503 to match the standard request
w.Header().Set("X-Aergia-Blocked", "true")
h.genericError(w, r, opLog, ext, format, path, 503)
w.Header().Set("X-Aergia-Denied", "true")
h.genericError(w, r, opLog, ext, format, path, "", 503)
}
} else {
w.Header().Set("X-Aergia-No-Namespace", "true")
h.genericError(w, r, opLog, ext, format, path, code)
h.genericError(w, r, opLog, ext, format, path, "", code)
}
h.setMetrics(r, start)
}
}

func (h *Unidler) genericError(w http.ResponseWriter, r *http.Request, opLog logr.Logger, ext, format, path string, code int) {
func (h *Unidler) genericError(w http.ResponseWriter, r *http.Request, opLog logr.Logger, ext, format, path, verifier string, code int) {
// otherwise just handle the generic http responses here
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
Expand All @@ -165,5 +168,23 @@ func (h *Unidler) genericError(w http.ResponseWriter, r *http.Request, opLog log
ServicePort: r.Header.Get(ServicePort),
RequestID: r.Header.Get(RequestID),
RefreshInterval: h.RefreshInterval,
Verifier: verifier,
})
}

// handle verifying the namespace name is signed by our secret
func (h *Unidler) verifyRequest(r *http.Request, ns *corev1.Namespace) (string, bool) {
if h.VerifiedUnidling {
if val, ok := ns.ObjectMeta.Annotations["idling.amazee.io/disable-hmac-verification"]; ok {
t, _ := strconv.ParseBool(val)
if t == true {
return "", true
}
}
// if hmac verification is enabled, perform the verification of the request
signedNamespace := hmacSigner(ns.Name, []byte(h.VerifiedSecret))
verifier := r.URL.Query().Get("verifier")
return signedNamespace, hmacVerifier(ns.Name, verifier, []byte(h.VerifiedSecret))
}
return "", true
}
23 changes: 23 additions & 0 deletions handlers/unidler/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package unidler

import (
"bufio"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"os"
"strings"
)
Expand Down Expand Up @@ -34,3 +37,23 @@ func ReadSliceFromFile(path string) ([]string, error) {
}
return lines, scanner.Err()
}

func hmacSign(ns string, secret []byte) []byte {
hmac := hmac.New(sha256.New, secret)
hmac.Write([]byte(ns))
dataHmac := hmac.Sum(nil)
return dataHmac
}

func hmacVerifier(verify, toverify string, secret []byte) bool {
hmacHex, err := hex.DecodeString(toverify)
if err != nil {
// error verifying, return false to reject
return false
}
return hmac.Equal(hmacSign(verify, secret), hmacHex)
}

func hmacSigner(ns string, secret []byte) string {
return hex.EncodeToString(hmacSign(ns, secret))
}
66 changes: 66 additions & 0 deletions handlers/unidler/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,69 @@ func TestReadSliceFromFile(t *testing.T) {
})
}
}
func Test_hmacVerifier(t *testing.T) {
type args struct {
verify string
toverify string
secret []byte
}
tests := []struct {
name string
args args
want bool
}{
{
name: "test1",
args: args{
verify: "namespace",
toverify: "5bee936fd2e7af2d7c2ba637ddd270814ccc7d449c3978bcfde637eac1ac228e",
secret: []byte("secret"),
},
want: true,
},
{
name: "test2",
args: args{
verify: "namespace",
toverify: "",
secret: []byte("secret"),
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hmacVerifier(tt.args.verify, tt.args.toverify, tt.args.secret); got != tt.want {
t.Errorf("hmacVerifier() = %v, want %v", got, tt.want)
}
})
}
}

func Test_hmacSigner(t *testing.T) {
type args struct {
ns string
secret []byte
}
tests := []struct {
name string
args args
want string
}{
{
name: "test1",
args: args{
ns: "namespace",
secret: []byte("secret"),
},
want: "5bee936fd2e7af2d7c2ba637ddd270814ccc7d449c3978bcfde637eac1ac228e",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hmacSigner(tt.args.ns, tt.args.secret); got != tt.want {
t.Errorf("hmacSigner() = %v, want %v", got, tt.want)
}
})
}
}
36 changes: 26 additions & 10 deletions handlers/unidler/unidler.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/go-logr/logr"
"github.com/prometheus/client_golang/prometheus"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/selection"
"k8s.io/apimachinery/pkg/types"
Expand All @@ -38,6 +39,8 @@ type Unidler struct {
RefreshInterval int
UnidlerHTTPPort int
Debug bool
VerifiedUnidling bool
VerifiedSecret string
RequestCount *prometheus.CounterVec
RequestDuration *prometheus.HistogramVec
Locks sync.Map
Expand All @@ -60,6 +63,7 @@ type pageData struct {
RequestID string
ErrorCode string
ErrorMessage string
Verifier string
}

const (
Expand Down Expand Up @@ -124,27 +128,27 @@ func faviconHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, fmt.Sprintf("%s\n", favicon))
}

func (h *Unidler) UnIdle(ctx context.Context, ns string, opLog logr.Logger) {
defer h.Locks.Delete(ns)
func (h *Unidler) Unidle(ctx context.Context, namespace *corev1.Namespace, opLog logr.Logger) {
defer h.Locks.Delete(namespace.Name)
// get the deployments in the namespace if they have the `watch=true` label
labelRequirements1, _ := labels.NewRequirement("idling.amazee.io/watch", selection.Equals, []string{"true"})
listOption := (&ctrlClient.ListOptions{}).ApplyOptions([]ctrlClient.ListOption{
ctrlClient.InNamespace(ns),
ctrlClient.InNamespace(namespace.Name),
client.MatchingLabelsSelector{
Selector: labels.NewSelector().Add(*labelRequirements1),
},
})
deployments := &appsv1.DeploymentList{}
if err := h.Client.List(ctx, deployments, listOption); err != nil {
opLog.Info(fmt.Sprintf("Unable to get any deployments - %s", ns))
opLog.Info(fmt.Sprintf("Unable to get any deployments - %s", namespace.Name))
return
}
for _, deploy := range deployments.Items {
// if the idled annotation is true
av, aok := deploy.ObjectMeta.Annotations["idling.amazee.io/idled"]
lv, lok := deploy.ObjectMeta.Labels["idling.amazee.io/idled"]
if aok && av == "true" || lok && lv == "true" {
opLog.Info(fmt.Sprintf("Deployment %s - Replicas %v - %s", deploy.ObjectMeta.Name, *deploy.Spec.Replicas, ns))
opLog.Info(fmt.Sprintf("Deployment %s - Replicas %v - %s", deploy.ObjectMeta.Name, *deploy.Spec.Replicas, namespace.Name))
if *deploy.Spec.Replicas == 0 {
// default to scaling to 1 replica
newReplicas := 1
Expand Down Expand Up @@ -176,21 +180,33 @@ func (h *Unidler) UnIdle(ctx context.Context, ns string, opLog logr.Logger) {
scaleDepConf := deploy.DeepCopy()
if err := h.Client.Patch(ctx, scaleDepConf, ctrlClient.RawPatch(types.MergePatchType, mergePatch)); err != nil {
// log it but try and scale the rest of the deployments anyway (some idled is better than none?)
opLog.Info(fmt.Sprintf("Error scaling deployment %s - %s", deploy.ObjectMeta.Name, ns))
opLog.Info(fmt.Sprintf("Error scaling deployment %s - %s", deploy.ObjectMeta.Name, namespace.Name))
} else {
opLog.Info(fmt.Sprintf("Deployment %s scaled to %d - %s", deploy.ObjectMeta.Name, newReplicas, ns))
opLog.Info(fmt.Sprintf("Deployment %s scaled to %d - %s", deploy.ObjectMeta.Name, newReplicas, namespace.Name))
}
}
}
}
// now wait for the pods of these deployments to be ready
// this could still result in 503 for users until the resulting services/endpoints are active and receiving traffic
for _, deploy := range deployments.Items {
opLog.Info(fmt.Sprintf("Waiting for %s to be running - %s", deploy.ObjectMeta.Name, ns))
opLog.Info(fmt.Sprintf("Waiting for %s to be running - %s", deploy.ObjectMeta.Name, namespace.Name))
timeout, cancel := context.WithTimeout(ctx, defaultPollTimeout)
defer cancel()
wait.PollUntilWithContext(timeout, defaultPollDuration, h.hasRunningPod(ctx, ns, deploy.Name))
wait.PollUntilWithContext(timeout, defaultPollDuration, h.hasRunningPod(ctx, namespace.Name, deploy.Name))
}
// remove the 503 code from any ingress objects that have it in this namespace
h.removeCodeFromIngress(ctx, ns, opLog)
h.removeCodeFromIngress(ctx, namespace.Name, opLog)
// label the namespace to indicate it is idled
namespaceCopy := namespace.DeepCopy()
mergePatch, _ := json.Marshal(map[string]interface{}{
"metadata": map[string]interface{}{
"labels": map[string]string{
"idling.amazee.io/idled": "true",
},
},
})
if err := h.Client.Patch(ctx, namespaceCopy, client.RawPatch(types.MergePatchType, mergePatch)); err != nil {
opLog.Info(fmt.Sprintf("Error patching namespace %s", namespace.Name))
}
}
Loading

0 comments on commit b2f920b

Please sign in to comment.