From 5e73523c388a7a3430d9ba5a4521c0f9412d5045 Mon Sep 17 00:00:00 2001
From: Matthew Baggett <matthew@baggett.me>
Date: Thu, 16 Jan 2025 19:38:54 +0100
Subject: [PATCH] Traefik basic auth + middlewares

---
 docker/service/debug.tf     |  6 ++++
 docker/service/labels.tf    |  1 +
 docker/service/outputs.tf   | 13 +++++++--
 docker/service/service.tf   |  4 +--
 docker/service/terraform.tf |  8 +++--
 docker/service/traefik.tf   | 58 ++++++++++++++++++++++++++++---------
 6 files changed, 70 insertions(+), 20 deletions(-)

diff --git a/docker/service/debug.tf b/docker/service/debug.tf
index 61f56b6..ec96173 100644
--- a/docker/service/debug.tf
+++ b/docker/service/debug.tf
@@ -23,5 +23,11 @@ resource "local_file" "debug" {
     traefik               = var.traefik
     placement_constraints = var.placement_constraints
     build_tags            = local.is_build ? local.tags : []
+    labels = {
+      computed = local.labels,
+      traefik  = local.traefik_labels,
+      provided = var.labels,
+      final    = local.merged_labels
+    }
   }))
 }
\ No newline at end of file
diff --git a/docker/service/labels.tf b/docker/service/labels.tf
index d8b783d..cfaaa7c 100644
--- a/docker/service/labels.tf
+++ b/docker/service/labels.tf
@@ -7,4 +7,5 @@ locals {
     "ooo.grey.service.name"  = var.service_name
     "ooo.grey.service.image" = local.image
   }
+  merged_labels = { for key, value in merge(local.labels, local.traefik_labels, var.labels) : key => value if value != null }
 }
\ No newline at end of file
diff --git a/docker/service/outputs.tf b/docker/service/outputs.tf
index b76b1c0..a2106ca 100644
--- a/docker/service/outputs.tf
+++ b/docker/service/outputs.tf
@@ -13,10 +13,19 @@ output "volumes" {
 output "docker_service" {
   value = docker_service.instance
 }
+locals {
+  first_auth = var.traefik.basic-auth-users != null ? "${try(var.traefik.basic-auth-users[0], null)}:${try(nonsensitive(random_password.password[var.traefik.basic-auth-users[0]].result), null)}@" : null
+}
 output "endpoint" {
   value = try(
-    "https://${var.traefik.domain}",
-    "http://${docker_service.instance.name}:${docker_service.instance.endpoint_spec[0].ports[0].target_port}",
+    "https://${local.first_auth}${var.traefik.domain}",
+    "http://${local.first_auth}${docker_service.instance.name}:${docker_service.instance.endpoint_spec[0].ports[0].target_port}",
     null
   )
+}
+
+output "basic_auth_users" {
+  value = {
+    for user in var.traefik.basic-auth-users : user => nonsensitive(htpasswd_password.htpasswd[user].bcrypt)
+  }
 }
\ No newline at end of file
diff --git a/docker/service/service.tf b/docker/service/service.tf
index 84185ef..4e22950 100644
--- a/docker/service/service.tf
+++ b/docker/service/service.tf
@@ -82,7 +82,7 @@ resource "docker_service" "instance" {
       # Apply the list of Container Labels
       dynamic "labels" {
         # Filter out null values
-        for_each = { for key, value in merge(local.labels, local.traefik_labels, var.labels) : key => value if value != null }
+        for_each = local.merged_labels
         content {
           label = labels.key
           value = labels.value
@@ -188,7 +188,7 @@ resource "docker_service" "instance" {
 
   # Service Labels
   dynamic "labels" {
-    for_each = { for key, value in merge(local.labels, local.traefik_labels, var.labels) : key => value if value != null }
+    for_each = local.merged_labels
     content {
       label = labels.key
       value = labels.value
diff --git a/docker/service/terraform.tf b/docker/service/terraform.tf
index 37772f1..1b422ad 100644
--- a/docker/service/terraform.tf
+++ b/docker/service/terraform.tf
@@ -4,11 +4,15 @@ terraform {
   required_providers {
     docker = {
       source  = "kreuzwerker/docker"
-      version = "~>3.0"
+      version = "~> 3.0"
     }
     local = {
       source  = "hashicorp/local"
-      version = "~>2.1"
+      version = "~> 2.1"
+    }
+    htpasswd = {
+      source  = "loafoe/htpasswd"
+      version = "~> 1.0"
     }
   }
 }
diff --git a/docker/service/traefik.tf b/docker/service/traefik.tf
index 22d9f28..d53cf59 100644
--- a/docker/service/traefik.tf
+++ b/docker/service/traefik.tf
@@ -1,19 +1,33 @@
 variable "traefik" {
   default = null
   type = object({
-    domain      = string
-    port        = optional(number)
-    non-ssl     = optional(bool, true)
-    ssl         = optional(bool, false)
-    rule        = optional(string)
-    middlewares = optional(list(string))
-    network = optional(object({
-      name = string
-      id   = string
-    }))
+    domain           = string
+    port             = optional(number)
+    non-ssl          = optional(bool, true)
+    ssl              = optional(bool, false)
+    rule             = optional(string)
+    middlewares      = optional(list(string))
+    network          = optional(object({ name = string, id = string }))
+    basic-auth-users = optional(list(string))
   })
   description = "Whether to enable traefik for the service."
 }
+resource "random_password" "password" {
+  for_each = toset(var.traefik.basic-auth-users)
+  length   = 16
+  special  = false
+}
+resource "random_password" "salt" {
+  for_each         = toset(var.traefik.basic-auth-users)
+  length           = 8
+  special          = true
+  override_special = "!@#%&*()-_=+[]{}<>:?"
+}
+resource "htpasswd_password" "htpasswd" {
+  for_each = toset(var.traefik.basic-auth-users)
+  password = random_password.password[each.key].result
+  salt     = random_password.salt[each.key].result
+}
 locals {
   is_traefik = var.traefik != null
   # Calculate the traefik labels to use if enabled
@@ -21,6 +35,21 @@ locals {
     substr(var.stack_name, 0, 20),
     substr(var.service_name, 0, 63 - 1 - 20),
   ])
+  traefik_basic_auth = (
+    local.is_traefik
+    ? (
+      var.traefik.basic-auth-users != null
+      ? {
+        "traefik.http.middlewares.${local.traefik_service}-auth.basicauth.users" = join(",", [
+          for user in var.traefik.basic-auth-users : "${user}:${htpasswd_password.htpasswd[user].bcrypt}"
+        ])
+      }
+      : {}
+    ) : {}
+  )
+  traefik_middlewares = concat(coalesce(var.traefik.middlewares, []), [
+    local.traefik_basic_auth != null ? "${local.traefik_service}-auth" : null
+  ])
   traefik_rule = (
     local.is_traefik
     ? (
@@ -51,12 +80,13 @@ locals {
       "traefik.http.services.${local.traefik_service}-ssl.loadbalancer.passhostheader" = var.traefik.ssl ? "true" : null
       "traefik.http.services.${local.traefik_service}-ssl.loadbalancer.server.port"    = var.traefik.ssl ? var.traefik.port : null
       },
-      (var.traefik.middlewares != null
+      (local.traefik_middlewares != null
         ? {
-          "traefik.http.routers.${local.traefik_service}.middlewares"     = join(",", var.traefik.middlewares)
-          "traefik.http.routers.${local.traefik_service}-ssl.middlewares" = join(",", var.traefik.middlewares)
+          "traefik.http.routers.${local.traefik_service}.middlewares"     = join(",", local.traefik_middlewares)
+          "traefik.http.routers.${local.traefik_service}-ssl.middlewares" = join(",", local.traefik_middlewares)
         } : {}
-      )
+      ),
+      local.traefik_basic_auth,
   ) : {})
 }