Skip to content

Commit

Permalink
Merge pull request #53 from GSA-TTS/egress-proxy
Browse files Browse the repository at this point in the history
Create an egress proxy module
  • Loading branch information
rahearn authored Oct 10, 2024
2 parents 78b3454 + 201d91d commit bc66708
Show file tree
Hide file tree
Showing 12 changed files with 361 additions and 9 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
strategy:
fail-fast: false
matrix:
path: ["cg_space", "clamav", "database", "domain", "redis", "s3"]
path: ["cg_space", "clamav", "database", "domain", "redis", "s3", "egress_proxy"]
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -28,7 +28,7 @@ jobs:
strategy:
fail-fast: false
matrix:
path: ["cg_space", "clamav", "database", "domain", "redis", "s3"]
path: ["cg_space", "clamav", "database", "domain", "redis", "s3", "egress_proxy"]
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,17 @@ jobs:
CF_PASSWORD: ${{ secrets.CF_PASSWORD }}
with:
path: ${{ matrix.module }}

test-egress-proxy:
runs-on: ubuntu-latest
name: Egress proxy integration test
env:
TERRAFORM_PRE_RUN: |
apt-get update
apt-get install -y zip
steps:
- uses: actions/checkout@v4
- name: terraform test egress_proxy
uses: dflook/terraform-test@v1
with:
path: egress_proxy
38 changes: 31 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# terraform-cloudgov

Terraform modules for working with cloud.gov commonly used by [18f/rails-template](https://github.com/18f/rails-template) based apps
Terraform modules for working with cloud.gov commonly used by [GSA-TTS/rails-template](https://github.com/GSA-TTS/rails-template) based apps

## Module Examples

Expand All @@ -10,7 +10,7 @@ Creates an RDS database based on the `rds_plan_name` variable and outputs the `i

```
module "database" {
source = "github.com/18f/terraform-cloudgov//database?ref=v1.0.0"
source = "github.com/GSA-TTS/terraform-cloudgov//database?ref=v1.1.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
Expand All @@ -32,7 +32,7 @@ Creates a Elasticache redis instance and outputs the `instance_id` for use elsew

```
module "redis" {
source = "github.com/18f/terraform-cloudgov//redis?ref=v1.0.0"
source = "github.com/GSA-TTS/terraform-cloudgov//redis?ref=v1.1.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
Expand All @@ -54,7 +54,7 @@ Creates an s3 bucket and outputs the `bucket_id` for use elsewhere.

```
module "s3" {
source = "github.com/18f/terraform-cloudgov//s3?ref=v1.0.0"
source = "github.com/GSA-TTS/terraform-cloudgov//s3?ref=v1.1.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
Expand All @@ -79,7 +79,7 @@ Note that the domain must be created in cloud.gov by an OrgManager before this m

```
module "domain" {
source = "github.com/18f/terraform-cloudgov//domain?ref=v1.0.0"
source = "github.com/GSA-TTS/terraform-cloudgov//domain?ref=v1.1.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
Expand All @@ -101,7 +101,7 @@ Notes:

```
module "clamav" {
source = "github.com/18f/terraform-cloudgov//clamav?ref=v1.0.0"
source = "github.com/GSA-TTS/terraform-cloudgov//clamav?ref=v1.1.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
Expand Down Expand Up @@ -129,7 +129,7 @@ Creates a new cloud.gov space, such as when creating an egress space, and output

```
module "egress_space" {
source = "github.com/18f/terraform-cloudgov//cg_space?ref=v1.0.0"
source = "github.com/GSA-TTS/terraform-cloudgov//cg_space?ref=v1.1.0"
cf_org_name = local.cf_org_name
cf_space_name = "${local.cf_space_name}-egress"
Expand All @@ -145,6 +145,30 @@ module "egress_space" {
}
```

### egress_proxy

Creates and configures an instance of cg-egress-proxy to proxy traffic from your apps.

Prerequities:

* existing client_space with already deployed apps
* existing public-egress space to deploy the proxy into

```
module "egress_proxy" {
source = "github.com/GSA-TTS/terraform-cloudgov//egress_proxy?ref=v1.1.0"
cf_org_name = local.cf_org_name
cf_space_name = "${local.cf_space_name}-egress"
client_space = local.cf_space_name
name = "egress-proxy"
allowlist = {
"source_app_name" = ["host.com:443", "otherhost.com:443"]
}
# see egress_proxy/variables.tf for full list of optional arguments
}
```

## Testing


Expand Down
4 changes: 4 additions & 0 deletions cg_space/outputs.tf
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
output "space_id" {
value = cloudfoundry_space.space.id
}

output "space_name" {
value = cloudfoundry_space.space.name
}
5 changes: 5 additions & 0 deletions cg_space/tests/creation.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ run "test_space_creation" {
condition = cloudfoundry_space.space.name == var.cf_space_name
error_message = "Space name should match the cf_space_name variable"
}

assert {
condition = cloudfoundry_space.space.name == output.space_name
error_message = "Space name output must match the new space"
}
}

run "test_manager_only" {
Expand Down
5 changes: 5 additions & 0 deletions egress_proxy/acl.tftpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
%{ for app, dests in list ~}
%{ for dest in dests ~}
${ split(":", dest)[0] }
%{ endfor ~}
%{ endfor ~}
127 changes: 127 additions & 0 deletions egress_proxy/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
locals {

# Make a clean list of the client apps for iteration purposes
clients = toset(keys(merge(var.allowlist, var.denylist)))

# Generate Caddy-compatible allow and deny ACLs, one target per line.
#
# For now, there's just one consolidated allowlist and denylist, no matter
# what apps they were specified for. Future improvments could improve this,
# but it would mean also changing the proxy to be both more complex (in terms
# of how the Caddyfile is constructed) and more discriminating (in terms of
# recognizing client apps based on GUIDs supplied by Envoy in request headers,
# as well as the destination ports). However, adding these improvements won't
# require modifying the module's interface, since we're already collecting
# that refined information.
allowacl = templatefile("${path.module}/acl.tftpl", { list = var.allowlist })
denyacl = templatefile("${path.module}/acl.tftpl", { list = var.denylist })
}

###
### Set up the authenticated egress application in the target space on apps.internal
###

data "cloudfoundry_domain" "internal" {
name = "apps.internal"
}

resource "cloudfoundry_route" "egress_route" {
space = data.cloudfoundry_space.egress_space.id
domain = data.cloudfoundry_domain.internal.id
hostname = substr("${var.cf_org_name}-${replace(var.cf_space_name, ".", "-")}-${var.name}", -63, -1)
# Yields something like: orgname-spacename-name.apps.internal, limited to the last 63 characters
}

resource "random_uuid" "username" {}
resource "random_password" "password" {
length = 16
special = false
}

data "cloudfoundry_space" "egress_space" {
org_name = var.cf_org_name
name = var.cf_space_name
}

# This zips up just the depoyable files from the specified gitref in the
# cg-egress-proxy repository
data "external" "proxyzip" {
program = ["/bin/sh", "prepare-proxy.sh"]
working_dir = path.module
query = {
gitref = var.gitref
}
}

resource "cloudfoundry_app" "egress_app" {
name = var.name
space = data.cloudfoundry_space.egress_space.id
path = "${path.module}/${data.external.proxyzip.result.path}"
source_code_hash = filesha256("${path.module}/${data.external.proxyzip.result.path}")
buildpack = "binary_buildpack"
command = "./caddy run --config Caddyfile"
memory = var.egress_memory
instances = var.instances
strategy = "rolling"

routes {
route = cloudfoundry_route.egress_route.id
}
environment = {
PROXY_PORTS : join(" ", var.allowports)
PROXY_ALLOW : local.allowacl
PROXY_DENY : local.denyacl
PROXY_USERNAME : random_uuid.username.result
PROXY_PASSWORD : random_password.password.result
}
}

###
### Set up network policies so that the clients can reach the proxy
###

data "cloudfoundry_space" "client_space" {
org_name = var.cf_org_name
name = var.client_space
}

data "cloudfoundry_app" "clients" {
for_each = local.clients
name_or_id = each.key
space = data.cloudfoundry_space.client_space.id
}

resource "cloudfoundry_network_policy" "client_routing" {
for_each = local.clients
policy {
source_app = data.cloudfoundry_app.clients[each.key].id
destination_app = cloudfoundry_app.egress_app.id
port = "61443"
}
}

###
### Create a credential service for bound clients to use when make requests of the proxy
###
locals {
https_proxy = "https://${random_uuid.username.result}:${random_password.password.result}@${cloudfoundry_route.egress_route.endpoint}:61443"
domain = cloudfoundry_route.egress_route.endpoint
username = random_uuid.username.result
password = random_password.password.result
protocol = "https"
port = 61443
app_id = cloudfoundry_app.egress_app.id
}

resource "cloudfoundry_user_provided_service" "credentials" {
name = "${var.name}-creds"
space = data.cloudfoundry_space.client_space.id
credentials = {
"uri" = local.https_proxy
"domain" = local.domain
"username" = local.username
"password" = local.password
"protocol" = local.protocol
"port" = local.port
}
}
29 changes: 29 additions & 0 deletions egress_proxy/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
output "https_proxy" {
value = local.https_proxy
sensitive = true
}

output "domain" {
value = local.domain
}

output "username" {
value = local.username
}

output "password" {
value = local.password
sensitive = true
}

output "protocol" {
value = local.protocol
}

output "app_id" {
value = local.app_id
}

output "port" {
value = local.port
}
25 changes: 25 additions & 0 deletions egress_proxy/prepare-proxy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/sh

# Exit if any step fails
set -e

eval "$(jq -r '@sh "GITREF=\(.gitref)"')"

popdir=$(pwd)

# Portable construct so this will work everywhere
# https://unix.stackexchange.com/a/84980
tmpdir=$(mktemp -d 2>/dev/null || mktemp -d -t 'mytmpdir')
cd "$tmpdir"

# Grab a copy of the zip file for the specified ref
curl -s -L https://github.com/GSA-TTS/cg-egress-proxy/archive/${GITREF}.zip --output local.zip

# Zip up just the proxy/ subdirectory for pushing
unzip -q -u local.zip \*/proxy/\*
zip -q -j -r ${popdir}/proxy.zip cg-egress-proxy-*/proxy

# Tell Terraform where to find it
cat << EOF
{ "path": "proxy.zip" }
EOF
9 changes: 9 additions & 0 deletions egress_proxy/providers.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
terraform {
required_version = "~> 1.0"
required_providers {
cloudfoundry = {
source = "cloudfoundry-community/cloudfoundry"
version = ">=0.53.1"
}
}
}
46 changes: 46 additions & 0 deletions egress_proxy/tests/creation.tftest.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
mock_provider "cloudfoundry" {}

variables {
cf_org_name = "gsa-tts-devtools-prototyping"
cf_space_name = "terraform-cloudgov-ci-tests-egress"
client_space = "terraform-cloudgov-ci-tests"
name = "terraform-egress-app"
allowlist = { "continuous_monitoring-staging" = ["raw.githubusercontent.com:443"] }
}

run "test_proxy_creation" {
assert {
condition = output.https_proxy == "https://${output.username}:${output.password}@${output.domain}:61443"
error_message = "HTTPS_PROXY output must match the correct form, got ${nonsensitive(output.https_proxy)}"
}

assert {
condition = output.domain == cloudfoundry_route.egress_route.endpoint
error_message = "Output domain must match the route endpoint"
}

assert {
condition = output.username == random_uuid.username.result
error_message = "Output username must come from the random_uuid resource"
}

assert {
condition = output.password == random_password.password.result
error_message = "Output password must come from the random_password resource"
}

assert {
condition = output.protocol == "https"
error_message = "protocol only supports https"
}

assert {
condition = output.app_id == cloudfoundry_app.egress_app.id
error_message = "Output app_id is the egress_app's ID"
}

assert {
condition = output.port == 61443
error_message = "port only supports 61443 internal https listener"
}
}
Loading

0 comments on commit bc66708

Please sign in to comment.