Skip to content

Commit

Permalink
feat: add ACME HTTP verification method for Keycloak Lets Encrypt TLS…
Browse files Browse the repository at this point in the history
… certificate exposure
  • Loading branch information
matihost committed Dec 16, 2024
1 parent a6e4be4 commit bbe7486
Show file tree
Hide file tree
Showing 8 changed files with 163 additions and 4 deletions.
19 changes: 19 additions & 0 deletions terraform/gcp/keycloak/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ else
MODE_STR := plan
endif



init:
ifndef ENV
$(error Environment ENV is not defined. Usage make run ENV=prod MODE=plan [DEBUG=true])
Expand Down Expand Up @@ -63,6 +65,23 @@ image: build-sql-connector ## builds keycloak image with support to PostreSQL GC
run-bash: ## run image with bash
docker run -it --entrypoint bash --rm quay.io/matihost/keycloak-postgres-cloudsql:$(TAG)

get-keycloak-gs-bucket: ## get GS bucket
ifndef ENV
$(error Environment ENV is not defined. Usage make get-keycloak-gs-bucket ENV=prod)
endif
@echo -n $(shell cd stage/$(ENV)/keycloak && terragrunt output keycloak_gs_bucket)


generate-letsencrypt-cert: ## generate Let's Encrypt TLS certificate, usage make generate-letsencrypt-cert DOMAIN=id.yourdomain.com
ifndef DOMAIN
$(error Env DOMAIN is not defined. Usage make generate-letsencrypt-cert DOMAIN=id.matihost.mooo.com)
endif
mkdir -p ~/.tls
sudo certbot certonly --manual -d $(DOMAIN)
sudo cp -Lr /etc/letsencrypt/live/$(DOMAIN) ~/.tls
sudo chown -R $(shell whoami):$(shell whoami) ~/.tls
sudo chmod -R go-rwx ~/.tls

help: ## show usage and tasks (default)
@eval $$(sed -E -n 's/^([\*\.a-zA-Z0-9_-]+):.*?## (.*)$$/printf "\\033[36m%-30s\\033[0m %s\\n" "\1" "\2" ;/; ta; b; :a p' $(MAKEFILE_LIST))
.DEFAULT_GOAL := help
Expand Down
57 changes: 57 additions & 0 deletions terraform/gcp/keycloak/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Exposed via GLB.

* Ensure you have DNS domain for [stage/dev/keycloak/terragrunt.hcl#input.url](stage/dev/keycloak/terragrunt.hcl). Change input.url parameter to meet DNS domain you wish site will be accessible from internet. I use free DNS subdomains from [https://freedns.afraid.org/](https://freedns.afraid.org/)

* (Optionally) TLS certification for your site. If you don't have one, by default Keycloak uses selfsigned TLS certificate. You may also get one via get Let's Encrypt TLS certification via HTTP ACME verfication method, follow instruction [HTTPS with Let's Encrpyt TLS certificate](#https-with-lets-encrypt-tls-certificate)

* Ensure Google Cloud Docker registry is configured. Run [../gcp-repository/](../gcp-repository/) deployment.

* Authenticate to GCP:
Expand Down Expand Up @@ -65,3 +67,58 @@ Exposed via GLB.
* Login to Keycloak admin console. Use: `/admin` prefix. TODO make super admin user and password env specific and taken from secret store.

* Follow [basic setup](https://www.keycloak.org/docs/latest/server_admin/#configuring-realms) of Keycloak - like SMTP configuration and create realm with name `id`.

## HTTPS with Let's Encrypt TLS Certificate

Keycloak deploymend takes TLS certificate from `~/.tls/id.domain` directory, or from `TLS_CRT`, `TLS_KEY` environment variables.
If none of these locations contains valid TLS files, the installation scripts creates self-signed certificate and use that for HTTPS exposure.

You can obtains Let's Encrypt with TXT ACME verification method.

If you DNS provider does not provide TXT entries or your prefer ACME HTTP verification method - then you need to :

* Install Keycloak without providing valid certificate. It will use selfsigned certificate.

Keycloak installation allows ACME HTTP verification path for Let's Encrypt verification, because it exposes `.well-known/acme-challenge/` path as static GS bucket content.


* Get name of GS where ACME verification file needs to be placed

```bash
make get-keycloak-gs-bucket ENV=prod
```

* Generate TLS certificate via Let's Encrypt: (certbot tool required):

```bash
make generate-letsencrypt-cert DOMAIN=id.mydomain.com
```

* Follow instruction on the screen. Essentially you need to create a file and *place it in above GS root directory*.

This is the proof that you control the site - and hence Let's Encrypt will generate TLS certificate for free for 3 months,
The make script also copies the generated certficate to `~/.tls` directory so that next Terraform invocation can access it.

_Warning_: If you intent to run deployment of this module from GitHub Actions `CD` workflow, not from your local environment - you need to place/change these files as GitHub Actions Environment secrets `TLS_CRT`, `TLS_KEY` respectively.

### Refresh TLS certificate

Let's Encrypt TLS certificate is valid for 3 months.
Run these steps before certificate is invalid:

```bash
# regenerate TLS certificate
make generate-letsencrypt-cert DOMAIN=id.mydomain.com

# check proposed changes
make run-keycloak MODE=plan ENV=prod


# when TLS certificate is to be changed only
make run-keycloak MODE=apply ENV=prod

# or
#
# if you run deployment from GitHub Actions CD workflow
# change GitHub Actions Environment secrets `TLS_CRT`, `TLS_KEY` and re-run CD workflow for that environment
```
45 changes: 45 additions & 0 deletions terraform/gcp/keycloak/module/keycloak/glb.tf
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ resource "google_compute_url_map" "keycloak" {
path_matcher {
name = "path-matcher-1"
default_service = google_compute_backend_service.keycloak.self_link

route_rules {
match_rules {
full_path_match = "/"
Expand All @@ -59,8 +60,26 @@ resource "google_compute_url_map" "keycloak" {
strip_query = true
}
}

# Workaround for the fact that Google Cloud does not allow exposing ACME HTTP verification path.
#
# # The .well-known/acme-challenge/verification_file needs to placed in the root GS bucket directory.
route_rules {
match_rules {
prefix_match = "/.well-known/acme-challenge/"
}
priority = 2
service = google_compute_backend_bucket.keycloak.self_link

route_action {
url_rewrite {
path_prefix_rewrite = "/"
}
}
}
}


name = local.name
project = var.project
}
Expand Down Expand Up @@ -120,3 +139,29 @@ resource "google_compute_backend_service" "keycloak" {
session_affinity = "CLIENT_IP"
timeout_sec = "30"
}


# Http2Https Redirect
resource "google_compute_global_forwarding_rule" "keycloak-xlb-http" {
ip_address = google_compute_global_address.keycloak.address
ip_protocol = "TCP"
load_balancing_scheme = "EXTERNAL_MANAGED"
name = "${local.name}-http2https-redirect"
port_range = "80-80"
target = google_compute_target_http_proxy.keycloak-xlb-http.self_link
}
resource "google_compute_target_http_proxy" "keycloak-xlb-http" {
name = "${local.name}-http2https-redirect"
url_map = google_compute_url_map.keycloak-xlb-http2https.self_link
}

resource "google_compute_url_map" "keycloak-xlb-http2https" {
default_url_redirect {
https_redirect = "true"
redirect_response_code = "MOVED_PERMANENTLY_DEFAULT"
strip_query = "false"
}

description = "Automatically generated HTTP to HTTPS redirect for the ${local.name}-http2https-redirect forwarding rule"
name = "${local.name}-http2https"
}
33 changes: 33 additions & 0 deletions terraform/gcp/keycloak/module/keycloak/gs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
resource "google_storage_bucket" "keycloak" {
name = local.name
force_destroy = true
# GCP free tier GS is free only with regional class in some US regions
location = var.region
storage_class = "REGIONAL"
uniform_bucket_level_access = true
}

# GCS does not support adding .well-known/acme-challenge/ entries
#
# Error uploading object .well-known/acme-challenge/...:
# googleapi: Error 400: ACME HTTP challenges are not supported.
#
# To expose Let's Encrypt location it needs to be done on LB routing level with path rewrite option.
#
# The .well-known/acme-challenge/verification_file needs to placed in the root GS bucket directory.


resource "google_compute_backend_bucket" "keycloak" {
name = local.name
description = "For static ACME HTTP challenges content exposure only"
bucket_name = google_storage_bucket.keycloak.name
enable_cdn = false
}


# Grant allUsers public read access to the bucket's objects
resource "google_storage_bucket_iam_member" "keycloak-public-access" {
bucket = google_storage_bucket.keycloak.self_link
role = "roles/storage.objectViewer"
member = "allUsers"
}
4 changes: 4 additions & 0 deletions terraform/gcp/keycloak/module/keycloak/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ output "keycloak_glb_public_ip" {
output "db_connection_name" {
value = google_sql_database_instance.keycloak.connection_name
}

output "keycloak_gs_bucket" {
value = google_storage_bucket.keycloak.name
}
4 changes: 2 additions & 2 deletions terraform/gcp/keycloak/stage/dev/keycloak/terragrunt.hcl
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
locals {
project = "${run_cmd("--terragrunt-quiet", "gcloud", "config", "get-value", "project")}"
cn = "id.matihost.dev.mooo.com"
tls_crt = "${run_cmd("--terragrunt-quiet", find_in_parent_folders("create-selfsigned-tls.sh"), local.cn)}"
tls_key = file(find_in_parent_folders("target/${local.cn}.key"))
tls_crt = try(file("~/.tls/${local.cn}/fullchain.pem"), try(get_env("TLS_CRT"), run_cmd("--terragrunt-quiet", find_in_parent_folders("create-selfsigned-tls.sh"), local.cn)))
tls_key = try(file("~/.tls/${local.cn}/privkey.pem"), try(get_env("TLS_KEY"), find_in_parent_folders("target/${local.cn}.key")))
}

# include does not import locals...
Expand Down
5 changes: 3 additions & 2 deletions terraform/gcp/keycloak/stage/prod/keycloak/terragrunt.hcl
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
locals {
project = "${run_cmd("--terragrunt-quiet", "gcloud", "config", "get-value", "project")}"
cn = "id.matihost.mooo.com"
tls_crt = "${run_cmd("--terragrunt-quiet", find_in_parent_folders("create-selfsigned-tls.sh"), local.cn)}"
tls_key = file(find_in_parent_folders("target/${local.cn}.key"))

tls_crt = try(file("~/.tls/${local.cn}/fullchain.pem"), try(get_env("TLS_CRT"), run_cmd("--terragrunt-quiet", find_in_parent_folders("create-selfsigned-tls.sh"), local.cn)))
tls_key = try(file("~/.tls/${local.cn}/privkey.pem"), try(get_env("TLS_KEY"), find_in_parent_folders("target/${local.cn}.key")))
}

# include does not import locals...
Expand Down

0 comments on commit bbe7486

Please sign in to comment.