From 19509576364ffc012a63416e8a17ccf3b3e988f8 Mon Sep 17 00:00:00 2001 From: David Larsen Date: Fri, 20 Mar 2026 17:42:54 -0400 Subject: [PATCH 01/14] Add Terraform template for Socket Firewall on Azure Container Apps --- main.tf | 283 +++++++++++++++++++++++++++++++++++++++ outputs.tf | 29 ++++ terraform.tfvars.example | 49 +++++++ variables.tf | 125 +++++++++++++++++ 4 files changed, 486 insertions(+) create mode 100644 main.tf create mode 100644 outputs.tf create mode 100644 terraform.tfvars.example create mode 100644 variables.tf diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..cb37f5e --- /dev/null +++ b/main.tf @@ -0,0 +1,283 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.80" + } + } +} + +provider "azurerm" { + features { + key_vault { + purge_soft_delete_on_destroy = false + } + } +} + +data "azurerm_client_config" "current" {} + +locals { + env_name = var.environment_name +} + +# ── Resource Group ─────────────────────────────────────────────────────────── + +resource "azurerm_resource_group" "this" { + name = var.resource_group_name + location = var.location + tags = var.tags +} + +# ── Log Analytics ──────────────────────────────────────────────────────────── + +resource "azurerm_log_analytics_workspace" "this" { + name = "log-${local.env_name}" + location = azurerm_resource_group.this.location + resource_group_name = azurerm_resource_group.this.name + sku = "PerGB2018" + retention_in_days = 30 + tags = var.tags +} + +# ── Managed Identity ───────────────────────────────────────────────────────── + +resource "azurerm_user_assigned_identity" "this" { + name = "id-${local.env_name}" + location = azurerm_resource_group.this.location + resource_group_name = azurerm_resource_group.this.name + tags = var.tags +} + +# ── Key Vault ──────────────────────────────────────────────────────────────── + +resource "azurerm_key_vault" "this" { + name = "kv-${local.env_name}" + location = azurerm_resource_group.this.location + resource_group_name = azurerm_resource_group.this.name + tenant_id = data.azurerm_client_config.current.tenant_id + sku_name = "standard" + soft_delete_retention_days = 7 + enable_rbac_authorization = true + tags = var.tags +} + +# Grant the managed identity access to read secrets +resource "azurerm_role_assignment" "kv_secrets_user" { + scope = azurerm_key_vault.this.id + role_definition_name = "Key Vault Secrets User" + principal_id = azurerm_user_assigned_identity.this.principal_id +} + +# Grant the deploying principal access to write secrets +resource "azurerm_role_assignment" "kv_secrets_officer" { + scope = azurerm_key_vault.this.id + role_definition_name = "Key Vault Secrets Officer" + principal_id = data.azurerm_client_config.current.object_id +} + +resource "azurerm_key_vault_secret" "socket_api_token" { + name = "socket-api-token" + value = var.socket_api_token + key_vault_id = azurerm_key_vault.this.id + + depends_on = [azurerm_role_assignment.kv_secrets_officer] +} + +resource "azurerm_key_vault_secret" "ssl_cert" { + name = "ssl-cert" + value = var.ssl_cert + key_vault_id = azurerm_key_vault.this.id + + depends_on = [azurerm_role_assignment.kv_secrets_officer] +} + +resource "azurerm_key_vault_secret" "ssl_key" { + name = "ssl-key" + value = var.ssl_key + key_vault_id = azurerm_key_vault.this.id + + depends_on = [azurerm_role_assignment.kv_secrets_officer] +} + +# ── Container Apps Environment ─────────────────────────────────────────────── + +resource "azurerm_container_app_environment" "this" { + name = "cae-${local.env_name}" + location = azurerm_resource_group.this.location + resource_group_name = azurerm_resource_group.this.name + log_analytics_workspace_id = azurerm_log_analytics_workspace.this.id + infrastructure_subnet_id = var.subnet_id + internal_load_balancer_enabled = true + tags = var.tags +} + +# ── Container Apps Environment Storage (socket.yml) ───────────────────────── +# Azure Container Apps supports Azure Files for volume mounts. For the config +# file and SSL certs we use Container App secrets + volume mounts of type +# "Secret", which are projected as files inside the container. + +# ── Container App ──────────────────────────────────────────────────────────── + +resource "azurerm_container_app" "firewall" { + name = "ca-${local.env_name}" + container_app_environment_id = azurerm_container_app_environment.this.id + resource_group_name = azurerm_resource_group.this.name + revision_mode = "Single" + tags = var.tags + + identity { + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.this.id] + } + + # ── Secrets (pulled from Key Vault via managed identity) ───────────────── + + secret { + name = "socket-api-token" + key_vault_secret_id = azurerm_key_vault_secret.socket_api_token.versionless_id + identity = azurerm_user_assigned_identity.this.id + } + + secret { + name = "ssl-cert" + key_vault_secret_id = azurerm_key_vault_secret.ssl_cert.versionless_id + identity = azurerm_user_assigned_identity.this.id + } + + secret { + name = "ssl-key" + key_vault_secret_id = azurerm_key_vault_secret.ssl_key.versionless_id + identity = azurerm_user_assigned_identity.this.id + } + + secret { + name = "socket-yml" + value = var.socket_yml_content + } + + # ── Ingress (internal only) ───────────────────────────────────────────── + + ingress { + external_enabled = false + target_port = 8443 + transport = "http" + + traffic_weight { + percentage = 100 + latest_revision = true + } + } + + # ── Template ──────────────────────────────────────────────────────────── + + template { + min_replicas = var.min_replicas + max_replicas = var.max_replicas + + # Volume: secrets projected as files + volume { + name = "config" + storage_type = "Secret" + } + + container { + name = "socket-registry-firewall" + image = var.firewall_image + cpu = var.cpu + memory = var.memory + + # ── Environment variables ────────────────────────────────────────── + + env { + name = "SOCKET_SECURITY_API_TOKEN" + secret_name = "socket-api-token" + } + + env { + name = "CONFIG_FILE" + value = "/mnt/config/socket-yml" + } + + env { + name = "SOCKET_FAIL_OPEN" + value = tostring(var.socket_fail_open) + } + + env { + name = "REDIS_ENABLED" + value = tostring(var.redis_enabled) + } + + env { + name = "REDIS_HOST" + value = var.redis_host + } + + env { + name = "REDIS_PORT" + value = tostring(var.redis_port) + } + + # ── Volume mounts ───────────────────────────────────────────────── + # Secret volumes project each secret as a file named after the secret. + # The container's entrypoint or an init script should copy/symlink: + # /mnt/config/socket-yml -> /app/socket.yml + # /mnt/config/ssl-cert -> /etc/nginx/ssl/server-cert.pem + # /mnt/config/ssl-key -> /etc/nginx/ssl/server-key.pem + + volume_mounts { + name = "config" + path = "/mnt/config" + } + + # ── Liveness probe ──────────────────────────────────────────────── + + liveness_probe { + transport = "HTTPS" + port = 8443 + path = "/health" + initial_delay = 15 + interval_seconds = 30 + timeout = 5 + failure_count_threshold = 3 + } + + # ── Readiness probe ─────────────────────────────────────────────── + + readiness_probe { + transport = "HTTPS" + port = 8443 + path = "/health" + interval_seconds = 10 + timeout = 3 + failure_count_threshold = 3 + success_count_threshold = 1 + } + + # ── Startup probe ───────────────────────────────────────────────── + + startup_probe { + transport = "HTTPS" + port = 8443 + path = "/health" + interval_seconds = 5 + timeout = 3 + failure_count_threshold = 30 + } + } + + # ── Scaling rule (CPU-based) ────────────────────────────────────────── + + custom_scale_rule { + name = "cpu-scaling" + custom_rule_type = "cpu" + metadata = { + type = "Utilization" + value = "70" + } + } + } +} diff --git a/outputs.tf b/outputs.tf new file mode 100644 index 0000000..fa0974a --- /dev/null +++ b/outputs.tf @@ -0,0 +1,29 @@ +output "fqdn" { + description = "Internal FQDN of the Socket Registry Firewall container app" + value = azurerm_container_app.firewall.ingress[0].fqdn +} + +output "resource_group_name" { + description = "Name of the resource group containing all resources" + value = azurerm_resource_group.this.name +} + +output "container_app_name" { + description = "Name of the Container App running the firewall" + value = azurerm_container_app.firewall.name +} + +output "container_app_environment_name" { + description = "Name of the Container Apps Environment" + value = azurerm_container_app_environment.this.name +} + +output "key_vault_name" { + description = "Name of the Key Vault storing secrets" + value = azurerm_key_vault.this.name +} + +output "managed_identity_client_id" { + description = "Client ID of the user-assigned managed identity" + value = azurerm_user_assigned_identity.this.client_id +} diff --git a/terraform.tfvars.example b/terraform.tfvars.example new file mode 100644 index 0000000..6fa2dd2 --- /dev/null +++ b/terraform.tfvars.example @@ -0,0 +1,49 @@ +# ── Required ───────────────────────────────────────────────────────────────── + +location = "eastus" +resource_group_name = "rg-socket-firewall" +environment_name = "socket-fw" + +# Socket API token (set via TF_VAR_socket_api_token env var or -var flag) +# socket_api_token = "" + +# VNet and subnet for internal networking. +# The subnet must be delegated to Microsoft.App/environments with a /23 CIDR minimum. +vnet_id = "/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/" +subnet_id = "/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks//subnets/" + +# ── Socket config ──────────────────────────────────────────────────────────── + +# Base64-encoded contents of socket.yml +# Generate with: base64 -i socket.yml +socket_yml_content = "IyBzb2NrZXQueW1sIGNvbnRlbnQgaGVyZQ==" + +socket_fail_open = false + +# ── SSL certificates ──────────────────────────────────────────────────────── + +# Base64-encoded PEM files (set via TF_VAR_ env vars for CI/CD) +# ssl_cert = "" +# ssl_key = "" + +# ── Redis (optional) ──────────────────────────────────────────────────────── + +redis_enabled = false +# redis_host = "redis.internal.example.com" +# redis_port = 6379 + +# ── Scaling ────────────────────────────────────────────────────────────────── + +firewall_image = "socketdev/socket-registry-firewall:latest" +min_replicas = 2 +max_replicas = 10 +cpu = 1.0 +memory = "2Gi" + +# ── Tags ───────────────────────────────────────────────────────────────────── + +tags = { + managed-by = "terraform" + service = "socket-registry-firewall" + environment = "production" +} diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..8a1fe1a --- /dev/null +++ b/variables.tf @@ -0,0 +1,125 @@ +variable "location" { + description = "Azure region for all resources" + type = string + default = "eastus" +} + +variable "resource_group_name" { + description = "Name of the resource group" + type = string + default = "rg-socket-firewall" +} + +variable "environment_name" { + description = "Name suffix for the Container Apps Environment and related resources" + type = string + default = "socket-fw" +} + +# ── Socket Firewall ────────────────────────────────────────────────────────── + +variable "firewall_image" { + description = "Docker image for the Socket Registry Firewall" + type = string + default = "socketdev/socket-registry-firewall:latest" +} + +variable "socket_api_token" { + description = "Socket Security API token" + type = string + sensitive = true +} + +variable "socket_fail_open" { + description = "Whether the firewall fails open when Socket API is unreachable" + type = bool + default = true +} + +variable "socket_yml_content" { + description = "Contents of socket.yml config file (base64-encoded)" + type = string +} + +# ── SSL ────────────────────────────────────────────────────────────────────── + +variable "ssl_cert" { + description = "PEM-encoded SSL certificate for the firewall (base64-encoded)" + type = string + sensitive = true +} + +variable "ssl_key" { + description = "PEM-encoded SSL private key for the firewall (base64-encoded)" + type = string + sensitive = true +} + +# ── Redis (optional) ──────────────────────────────────────────────────────── + +variable "redis_enabled" { + description = "Enable Redis caching" + type = bool + default = false +} + +variable "redis_host" { + description = "Redis hostname" + type = string + default = "" +} + +variable "redis_port" { + description = "Redis port" + type = number + default = 6379 +} + +# ── Scaling ────────────────────────────────────────────────────────────────── + +variable "min_replicas" { + description = "Minimum number of container replicas" + type = number + default = 1 +} + +variable "max_replicas" { + description = "Maximum number of container replicas" + type = number + default = 5 +} + +variable "cpu" { + description = "CPU cores allocated to the container (e.g. 0.5, 1.0, 2.0)" + type = number + default = 1.0 +} + +variable "memory" { + description = "Memory allocated to the container in Gi (e.g. 1Gi, 2Gi)" + type = string + default = "2Gi" +} + +# ── Networking ─────────────────────────────────────────────────────────────── + +variable "vnet_id" { + description = "Resource ID of the VNet for internal networking" + type = string +} + +variable "subnet_id" { + description = "Resource ID of the subnet delegated to Container Apps Environment" + type = string +} + +# ── Tags ───────────────────────────────────────────────────────────────────── + +variable "tags" { + description = "Tags applied to all resources" + type = map(string) + default = { + managed-by = "terraform" + service = "socket-registry-firewall" + } +} From bcc42cfdb4355e5f7fb79d9837764271953ec8a4 Mon Sep 17 00:00:00 2001 From: David Larsen Date: Fri, 20 Mar 2026 17:43:37 -0400 Subject: [PATCH 02/14] Add README --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..b480548 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Socket Firewall - Azure Container Apps (Terraform) + +Terraform template for deploying the [Socket Registry Firewall](https://github.com/SocketDev/socket-nginx-firewall) on Azure Container Apps. + +## What it provisions + +- Resource Group +- Container Apps Environment with VNet integration and internal load balancer +- Container App with health probes and CPU-based scaling +- Key Vault for credentials (API token, SSL cert/key) +- User-assigned Managed Identity (for Key Vault access) +- Log Analytics workspace + +## Prerequisites + +- Terraform >= 1.5 +- Azure CLI configured (`az login`) +- A VNet with a subnet delegated to Container Apps (minimum /23 CIDR) +- A Socket.dev API token ([create one here](https://socket.dev/dashboard/org/settings/api-tokens)) with `packages` and `entitlements:list` scopes +- SSL certificate and private key files + +## Usage + +```bash +cp terraform.tfvars.example terraform.tfvars +# Edit terraform.tfvars with your values + +terraform init +terraform plan +terraform apply +``` + +## Inputs + +See `variables.tf` for all configurable inputs with descriptions and defaults. + +Key variables: +- `socket_api_token` - Socket.dev API token (required) +- `socket_yml_content` - Contents of your socket.yml config (required) +- `ssl_cert` / `ssl_key` - SSL certificate PEM content (required) +- `subnet_id` - Delegated subnet for the Container Apps Environment (required) +- `min_replicas` / `max_replicas` - Scaling bounds (default: 1 / 4) +- `cpu` / `memory` - Container resources (default: 2.0 / 4Gi) + +## Notes + +Azure Container Apps mounts secrets as files in a shared volume at `/mnt/config/`. The template sets the `CONFIG_FILE` env var so the firewall reads socket.yml from the correct path. SSL certificate paths in your `socket.yml` should reference `/mnt/config/ssl-cert` and `/mnt/config/ssl-key`. + +## Other deployment options + +- **Already on Kubernetes?** Use the [Helm chart](https://github.com/socketdev-demo/socket-firewall-helm) +- **On AWS?** See [socket-firewall-aws-ecs-fargate](https://github.com/socketdev-demo/socket-firewall-aws-ecs-fargate) +- **On GCP?** See [socket-firewall-gcp-cloud-run](https://github.com/socketdev-demo/socket-firewall-gcp-cloud-run) From 71f8796f9f16e58e3cff52af54b69b0057baa1f9 Mon Sep 17 00:00:00 2001 From: David Larsen Date: Sat, 21 Mar 2026 12:44:37 -0400 Subject: [PATCH 03/14] Add auto-generated socket.yml from registries variable, docs improvements Replace manual socket_yml_content input with auto-generation from a registries variable and domain hostname. The firewall's path_routing domain must match the Host header clients send, so this is now a required variable. Changes: - Generate socket.yml from registries map and domain variable using yamlencode, removing need for manual base64-encoded config - Remove SOCKET_FAIL_OPEN env var (now set in generated socket.yml) - Add .gitignore for tfstate, tfvars, .terraform/ - Add Registries, Verify the deployment, and Troubleshooting to README - Update terraform.tfvars.example with new variables --- .gitignore | 7 ++++ README.md | 84 ++++++++++++++++++++++++++++++++++++---- main.tf | 36 ++++++++++++++--- terraform.tfvars.example | 33 ++++++++-------- variables.tf | 12 +++++- 5 files changed, 140 insertions(+), 32 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b47dc95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.terraform/ +*.tfstate +*.tfstate.backup +.terraform.lock.hcl +tfplan* +terraform.tfvars +.DS_Store diff --git a/README.md b/README.md index b480548..f7a209e 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,86 @@ terraform apply See `variables.tf` for all configurable inputs with descriptions and defaults. Key variables: -- `socket_api_token` - Socket.dev API token (required) -- `socket_yml_content` - Contents of your socket.yml config (required) -- `ssl_cert` / `ssl_key` - SSL certificate PEM content (required) -- `subnet_id` - Delegated subnet for the Container Apps Environment (required) -- `min_replicas` / `max_replicas` - Scaling bounds (default: 1 / 4) -- `cpu` / `memory` - Container resources (default: 2.0 / 4Gi) +- `socket_api_token` - Socket.dev API token (required, sensitive) +- `domain` - Hostname clients use to reach the firewall (required). Set to the FQDN from terraform output or your custom DNS name. +- `registries` - Map of registry name to upstream URL (default: npm only) +- `ssl_cert` / `ssl_key` - SSL certificate PEM content (required, sensitive) +- `subnet_id` / `vnet_id` - Network configuration (required) +- `min_replicas` / `max_replicas` - Scaling bounds (default: 1 / 5) +- `cpu` / `memory` - Container resources (default: 1.0 / 2Gi) + +## Registries + +The `registries` variable controls path-based routing. Each entry creates a route at `/` that proxies to the upstream URL. + +```hcl +registries = { + npm = "https://registry.npmjs.org" + pypi = "https://pypi.org" + maven = "https://repo1.maven.org/maven2" +} +``` + +Configure npm to use the firewall: + +```bash +npm config set registry https://registry.company.com/npm +``` + +Configure pip: + +```bash +pip install --index-url https://registry.company.com/pypi/simple +``` + +## Outputs + +- `fqdn` - Internal FQDN of the Container App +- `resource_group_name` - Resource group name +- `container_app_name` - Container App name +- `container_app_environment_name` - Container Apps Environment name +- `key_vault_name` - Key Vault name +- `managed_identity_client_id` - Managed Identity client ID + +## Verify the deployment + +The firewall runs on an internal load balancer, so you must test from a VM or resource within the VNet. + +Check the health endpoint: + +```bash +curl -k https:///health +``` + +Test npm package resolution through the firewall: + +```bash +npm config set registry https:///npm +npm view lodash version +``` + +## Troubleshooting + +**Containers keep restarting** +Check logs in Log Analytics. The most common cause is an invalid or missing SSL certificate. Verify your `ssl_cert` and `ssl_key` values are base64-encoded PEM files. + +**404 errors on package requests** +The `domain` variable must match the Host header that clients send. If you are using the Container App FQDN directly, set `domain` to that FQDN. Run `terraform output fqdn` to get the value. + +**Packages resolve but are not scanned** +Verify your API token has the `packages` and `entitlements:list` scopes. You can test the token directly: +```bash +curl -H "Authorization: Bearer $SOCKET_API_TOKEN" https://api.socket.dev/v0/report/supported +``` + +**Key Vault soft-delete conflict** +If you destroy and recreate the stack, Azure retains the Key Vault in a soft-deleted state for 7 days. Either purge it manually (`az keyvault purge --name `) or use a different `environment_name`. ## Notes -Azure Container Apps mounts secrets as files in a shared volume at `/mnt/config/`. The template sets the `CONFIG_FILE` env var so the firewall reads socket.yml from the correct path. SSL certificate paths in your `socket.yml` should reference `/mnt/config/ssl-cert` and `/mnt/config/ssl-key`. +Azure Container Apps mounts secrets as files in a shared volume at `/mnt/config/`. The template sets the `CONFIG_FILE` env var so the firewall reads socket.yml from the correct path. SSL certificate paths in the generated `socket.yml` reference `/mnt/config/ssl-cert` and `/mnt/config/ssl-key`. + +The `socket.yml` config is auto-generated from the `registries` and `domain` variables. You do not need to write or encode it manually. ## Other deployment options diff --git a/main.tf b/main.tf index cb37f5e..71527aa 100644 --- a/main.tf +++ b/main.tf @@ -21,6 +21,35 @@ data "azurerm_client_config" "current" {} locals { env_name = var.environment_name + + routes = [for name, upstream in var.registries : { + path = "/${name}" + upstream = upstream + registry = name + }] + + socket_yml = yamlencode({ + ports = { + http = 8080 + https = 8443 + } + socket = { + api_url = "https://api.socket.dev" + fail_open = var.socket_fail_open + } + cache = { + ttl = 600 + } + ssl = { + cert = "/mnt/config/ssl-cert" + key = "/mnt/config/ssl-key" + } + path_routing = { + enabled = true + domain = "${var.domain} localhost" + routes = local.routes + } + }) } # ── Resource Group ─────────────────────────────────────────────────────────── @@ -155,7 +184,7 @@ resource "azurerm_container_app" "firewall" { secret { name = "socket-yml" - value = var.socket_yml_content + value = local.socket_yml } # ── Ingress (internal only) ───────────────────────────────────────────── @@ -201,11 +230,6 @@ resource "azurerm_container_app" "firewall" { value = "/mnt/config/socket-yml" } - env { - name = "SOCKET_FAIL_OPEN" - value = tostring(var.socket_fail_open) - } - env { name = "REDIS_ENABLED" value = tostring(var.redis_enabled) diff --git a/terraform.tfvars.example b/terraform.tfvars.example index 6fa2dd2..007e391 100644 --- a/terraform.tfvars.example +++ b/terraform.tfvars.example @@ -1,5 +1,4 @@ -# ── Required ───────────────────────────────────────────────────────────────── - +# Required location = "eastus" resource_group_name = "rg-socket-firewall" environment_name = "socket-fw" @@ -7,41 +6,41 @@ environment_name = "socket-fw" # Socket API token (set via TF_VAR_socket_api_token env var or -var flag) # socket_api_token = "" +# Hostname clients use to reach the firewall. +# After first deploy, set to the FQDN from terraform output, or use your custom DNS name. +domain = "registry.company.com" + # VNet and subnet for internal networking. # The subnet must be delegated to Microsoft.App/environments with a /23 CIDR minimum. vnet_id = "/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/" subnet_id = "/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks//subnets/" -# ── Socket config ──────────────────────────────────────────────────────────── - -# Base64-encoded contents of socket.yml -# Generate with: base64 -i socket.yml -socket_yml_content = "IyBzb2NrZXQueW1sIGNvbnRlbnQgaGVyZQ==" - -socket_fail_open = false +# Registries to proxy (default: npm only) +# Each entry creates a path-based route: /npm, /pypi, etc. +registries = { + npm = "https://registry.npmjs.org" + # pypi = "https://pypi.org" + # maven = "https://repo1.maven.org/maven2" +} -# ── SSL certificates ──────────────────────────────────────────────────────── +socket_fail_open = true -# Base64-encoded PEM files (set via TF_VAR_ env vars for CI/CD) +# SSL certificates (base64-encoded PEM, set via TF_VAR_ env vars for CI/CD) # ssl_cert = "" # ssl_key = "" -# ── Redis (optional) ──────────────────────────────────────────────────────── - +# Redis (optional) redis_enabled = false # redis_host = "redis.internal.example.com" # redis_port = 6379 -# ── Scaling ────────────────────────────────────────────────────────────────── - +# Scaling firewall_image = "socketdev/socket-registry-firewall:latest" min_replicas = 2 max_replicas = 10 cpu = 1.0 memory = "2Gi" -# ── Tags ───────────────────────────────────────────────────────────────────── - tags = { managed-by = "terraform" service = "socket-registry-firewall" diff --git a/variables.tf b/variables.tf index 8a1fe1a..79f8b6f 100644 --- a/variables.tf +++ b/variables.tf @@ -36,8 +36,16 @@ variable "socket_fail_open" { default = true } -variable "socket_yml_content" { - description = "Contents of socket.yml config file (base64-encoded)" +variable "registries" { + description = "Map of registry name to upstream URL. Each entry creates a path-based route (e.g., npm = https://registry.npmjs.org creates /npm)." + type = map(string) + default = { + npm = "https://registry.npmjs.org" + } +} + +variable "domain" { + description = "Hostname for path-based routing (e.g., registry.company.com). Use the FQDN from the first deploy or your custom DNS name." type = string } From e77eb85e470f7f2578f15829c75a4b715cf828a9 Mon Sep 17 00:00:00 2001 From: David Larsen Date: Sat, 21 Mar 2026 12:52:41 -0400 Subject: [PATCH 04/14] Add blocked package test, npm cache warning, fail_open troubleshooting --- README.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f7a209e..cc5bda5 100644 --- a/README.md +++ b/README.md @@ -78,19 +78,21 @@ pip install --index-url https://registry.company.com/pypi/simple ## Verify the deployment -The firewall runs on an internal load balancer, so you must test from a VM or resource within the VNet. - -Check the health endpoint: +The firewall runs on an internal load balancer, so test from a VM or resource within the VNet. ```bash +# Health check — should include "path-routing" in the response curl -k https:///health -``` +# Expected: SocketFirewall/x.x.x - Health OK - path-routing (...) -Test npm package resolution through the firewall: +# Test a safe package +npm install lodash --registry https:///npm -```bash -npm config set registry https:///npm -npm view lodash version +# Test a blocked package +# IMPORTANT: clear npm cache first, or cached tarballs bypass the firewall +npm cache clean --force +npm install form-data@2.3.3 --registry https:///npm --prefer-online +# Expected: E403 "Blocked by Security Policy" ``` ## Troubleshooting @@ -101,11 +103,8 @@ Check logs in Log Analytics. The most common cause is an invalid or missing SSL **404 errors on package requests** The `domain` variable must match the Host header that clients send. If you are using the Container App FQDN directly, set `domain` to that FQDN. Run `terraform output fqdn` to get the value. -**Packages resolve but are not scanned** -Verify your API token has the `packages` and `entitlements:list` scopes. You can test the token directly: -```bash -curl -H "Authorization: Bearer $SOCKET_API_TOKEN" https://api.socket.dev/v0/report/supported -``` +**Packages install but are not scanned** +Verify your API token has the `packages` and `entitlements:list` scopes. With `socket_fail_open = true` (the default), invalid or missing tokens silently pass all packages through without scanning. Check container logs for `Firewall access validation failed` or `401` errors. **Key Vault soft-delete conflict** If you destroy and recreate the stack, Azure retains the Key Vault in a soft-deleted state for 7 days. Either purge it manually (`az keyvault purge --name `) or use a different `environment_name`. From 00e1c1f48e02d5b4b3cb9d408eaee9819666abdd Mon Sep 17 00:00:00 2001 From: David Larsen Date: Sat, 21 Mar 2026 12:58:41 -0400 Subject: [PATCH 05/14] Use peacenotwar@9.1.3 as blocked package example instead of form-data --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cc5bda5..499c363 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ npm install lodash --registry https:///npm # Test a blocked package # IMPORTANT: clear npm cache first, or cached tarballs bypass the firewall npm cache clean --force -npm install form-data@2.3.3 --registry https:///npm --prefer-online +npm install peacenotwar@9.1.3 --registry https:///npm --prefer-online # Expected: E403 "Blocked by Security Policy" ``` From 8458d098e0de17509a1af0790b199c6d131ceac9 Mon Sep 17 00:00:00 2001 From: David Larsen Date: Mon, 23 Mar 2026 14:36:25 -0400 Subject: [PATCH 06/14] Fix startup probe failure_count_threshold exceeding Azure max Azure Container Apps limits failure_count_threshold to 1-10. The previous value of 30 caused terraform plan to fail with a validation error. --- main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.tf b/main.tf index 71527aa..49fc8dc 100644 --- a/main.tf +++ b/main.tf @@ -289,7 +289,7 @@ resource "azurerm_container_app" "firewall" { path = "/health" interval_seconds = 5 timeout = 3 - failure_count_threshold = 30 + failure_count_threshold = 10 } } From 0c89195806421e446f096e4d06585b3d720190cc Mon Sep 17 00:00:00 2001 From: David Larsen Date: Mon, 23 Mar 2026 19:32:25 -0400 Subject: [PATCH 07/14] Add Artifactory route examples, debug logging, and recently-published config - Remove api_url from socket.yml (defaults to api.socket.dev) - Add commented Artifactory/upstream route examples to main.tf and tfvars - Add debug_logging_enabled and debug_user_agent_filter variables - Add recently_published_enabled_ecosystems variable - Update README with new variables and upstream mode documentation --- README.md | 22 +++++++++++--- main.tf | 63 ++++++++++++++++++++++++++-------------- terraform.tfvars.example | 16 ++++++++++ variables.tf | 22 ++++++++++++++ 4 files changed, 97 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 499c363..e762469 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,16 @@ Key variables: - `subnet_id` / `vnet_id` - Network configuration (required) - `min_replicas` / `max_replicas` - Scaling bounds (default: 1 / 5) - `cpu` / `memory` - Container resources (default: 1.0 / 2Gi) +- `debug_logging_enabled` - Enable debug logging for HTTP requests/responses (default: false) +- `debug_user_agent_filter` - Glob pattern to filter debug logs by user-agent (default: "") +- `recently_published_enabled_ecosystems` - Ecosystems to enforce recently-published blocking (default: []) ## Registries The `registries` variable controls path-based routing. Each entry creates a route at `/` that proxies to the upstream URL. +### Direct routes (firewall in front of public registries) + ```hcl registries = { npm = "https://registry.npmjs.org" @@ -55,16 +60,25 @@ registries = { } ``` -Configure npm to use the firewall: - ```bash npm config set registry https://registry.company.com/npm +pip install --index-url https://registry.company.com/pypi/simple ``` -Configure pip: +### Upstream mode (firewall in front of Artifactory) + +If you use Artifactory (or another artifact repository manager), use `/repository/` paths to match Artifactory's URL convention: + +```hcl +registries = { + "repository/npm-remote" = "https://company.jfrog.io/artifactory/api/npm/npm-remote" + "repository/pypi-remote" = "https://company.jfrog.io/artifactory/api/pypi/pypi-remote" +} +``` ```bash -pip install --index-url https://registry.company.com/pypi/simple +npm config set registry https://registry.company.com/repository/npm-remote +pip install --index-url https://registry.company.com/repository/pypi-remote/simple ``` ## Outputs diff --git a/main.tf b/main.tf index 49fc8dc..a06096e 100644 --- a/main.tf +++ b/main.tf @@ -22,34 +22,53 @@ data "azurerm_client_config" "current" {} locals { env_name = var.environment_name + # Direct routes: /npm -> https://registry.npmjs.org + # For Artifactory (upstream mode), use /repository/ paths instead: + # registries = { + # "repository/npm-remote" = "https://company.jfrog.io/artifactory/api/npm/npm-remote" + # } + # This creates a route at /repository/npm-remote that proxies to your Artifactory + # virtual or remote repository. Configure npm with: + # npm config set registry https:///repository/npm-remote + routes = [for name, upstream in var.registries : { path = "/${name}" upstream = upstream registry = name }] - socket_yml = yamlencode({ - ports = { - http = 8080 - https = 8443 - } - socket = { - api_url = "https://api.socket.dev" - fail_open = var.socket_fail_open - } - cache = { - ttl = 600 - } - ssl = { - cert = "/mnt/config/ssl-cert" - key = "/mnt/config/ssl-key" - } - path_routing = { - enabled = true - domain = "${var.domain} localhost" - routes = local.routes - } - }) + socket_yml = yamlencode(merge( + { + ports = { + http = 8080 + https = 8443 + } + socket = { + fail_open = var.socket_fail_open + } + cache = { + ttl = 600 + } + ssl = { + cert = "/mnt/config/ssl-cert" + key = "/mnt/config/ssl-key" + } + path_routing = { + enabled = true + domain = "${var.domain} localhost" + routes = local.routes + } + }, + var.debug_logging_enabled ? { + debug = merge( + { logging_enabled = true }, + var.debug_user_agent_filter != "" ? { user_agent_filter = var.debug_user_agent_filter } : {} + ) + } : {}, + length(var.recently_published_enabled_ecosystems) > 0 ? { + recently_published_enabled_ecosystems = var.recently_published_enabled_ecosystems + } : {} + )) } # ── Resource Group ─────────────────────────────────────────────────────────── diff --git a/terraform.tfvars.example b/terraform.tfvars.example index 007e391..40e9765 100644 --- a/terraform.tfvars.example +++ b/terraform.tfvars.example @@ -17,14 +17,30 @@ subnet_id = "/subscriptions//resourceGroups//providers/Microsoft.Net # Registries to proxy (default: npm only) # Each entry creates a path-based route: /npm, /pypi, etc. +# +# Direct routes (firewall in front of public registries): registries = { npm = "https://registry.npmjs.org" # pypi = "https://pypi.org" # maven = "https://repo1.maven.org/maven2" } +# +# Artifactory / upstream mode (firewall in front of Artifactory): +# registries = { +# "repository/npm-remote" = "https://company.jfrog.io/artifactory/api/npm/npm-remote" +# "repository/pypi-remote" = "https://company.jfrog.io/artifactory/api/pypi/pypi-remote" +# } socket_fail_open = true +# Debug logging (optional, useful for troubleshooting) +# debug_logging_enabled = true +# debug_user_agent_filter = "pip" # glob pattern, case-insensitive + +# Recently published package enforcement (optional) +# Blocks packages published within the last 72 hours for the listed ecosystems. +# recently_published_enabled_ecosystems = ["npm"] + # SSL certificates (base64-encoded PEM, set via TF_VAR_ env vars for CI/CD) # ssl_cert = "" # ssl_key = "" diff --git a/variables.tf b/variables.tf index 79f8b6f..d8c977a 100644 --- a/variables.tf +++ b/variables.tf @@ -49,6 +49,28 @@ variable "domain" { type = string } +# ── Debug Logging ───────────────────────────────────────────────────────────── + +variable "debug_logging_enabled" { + description = "Enable debug logging for HTTP requests and responses" + type = bool + default = false +} + +variable "debug_user_agent_filter" { + description = "Glob pattern to filter debug logs by user-agent (case-insensitive, e.g. 'pip' or 'npm*')" + type = string + default = "" +} + +# ── Recently Published ──────────────────────────────────────────────────────── + +variable "recently_published_enabled_ecosystems" { + description = "List of ecosystems to enforce recently-published package blocking (e.g. [\"npm\", \"pypi\"])" + type = list(string) + default = [] +} + # ── SSL ────────────────────────────────────────────────────────────────────── variable "ssl_cert" { From 34c4d66429d286365ce00354f73e7f68f62c2594 Mon Sep 17 00:00:00 2001 From: David Larsen Date: Tue, 24 Mar 2026 19:52:51 -0400 Subject: [PATCH 08/14] Add self-signed cert generation, debug env vars, and troubleshooting outputs - Add generate_self_signed_cert option that creates a proper server cert with SANs derived from the domain variable (covers Front Door private link setups where cert subject name validation is required) - Add SOCKET_FAIL_OPEN, SOCKET_LOG_LEVEL, SOCKET_DEBUG_LOGGING_ENABLED, and SOCKET_DEBUG_USER_AGENT_FILTER env vars (the firewall reads these from env vars, not socket.yml) - Add log_level variable (set to "debug" for TLS handshake details) - Add ssl_cert_sans output to verify cert SANs after deploy - Add troubleshooting output with deployment-specific debug commands - Document Front Door SSLMismatchedSNI, tarball URL rewriting, and secret propagation issues in README --- README.md | 27 +++++++++++++- main.tf | 76 ++++++++++++++++++++++++++++++++++++++-- outputs.tf | 42 ++++++++++++++++++++++ terraform.tfvars.example | 21 ++++++++--- variables.tf | 25 +++++++++++-- 5 files changed, 180 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e762469..fe44d63 100644 --- a/README.md +++ b/README.md @@ -111,15 +111,40 @@ npm install peacenotwar@9.1.3 --registry https:///npm --prefer-online ## Troubleshooting +After `terraform apply`, run `terraform output troubleshooting` to see useful debugging commands for your deployment. + +To enable verbose logging, set `log_level = "debug"` in your tfvars and redeploy. This sets nginx error_log to debug level, showing TLS handshake details, upstream connections, and request routing decisions. + **Containers keep restarting** -Check logs in Log Analytics. The most common cause is an invalid or missing SSL certificate. Verify your `ssl_cert` and `ssl_key` values are base64-encoded PEM files. +Check logs in Log Analytics or with `az containerapp logs show`. The most common cause is an invalid or missing SSL certificate. If using the default self-signed cert (`generate_self_signed_cert = true`), verify the SANs with `terraform output ssl_cert_sans`. **404 errors on package requests** The `domain` variable must match the Host header that clients send. If you are using the Container App FQDN directly, set `domain` to that FQDN. Run `terraform output fqdn` to get the value. +**Azure Front Door: 421 SSLMismatchedSNI** +Front Door validates that the Host header matches a custom domain configured on the Front Door profile. This error means either: +1. The custom domain is not associated with the Front Door endpoint/route, or +2. The origin host header does not match the cert's SANs. + +The `domain` variable controls the cert SANs (when using the self-signed cert). Include all hostnames that Front Door might send, separated by spaces: +```hcl +domain = "registry.company.com ca-socket-fw.xxxxx.eastus.azurecontainerapps.io" +``` + +**Tarball URLs point to the Container App FQDN instead of the customer-facing domain** +The firewall rewrites tarball URLs using the `Host` header it receives. If Front Door's origin host header is set to the Container App FQDN, tarball URLs will use that FQDN, and npm clients will try to download tarballs directly (bypassing Front Door), which fails with ECONNRESET. + +Fix: Set the Front Door origin host header to the customer-facing domain (e.g., `registry.company.com`), and make sure that domain is included in the `domain` variable so the cert's SANs match. + **Packages install but are not scanned** Verify your API token has the `packages` and `entitlements:list` scopes. With `socket_fail_open = true` (the default), invalid or missing tokens silently pass all packages through without scanning. Check container logs for `Firewall access validation failed` or `401` errors. +**Secret changes not taking effect after terraform apply** +Container Apps secret volumes are immutable per revision. Restarting the same revision reloads the same secrets. Force a new revision: +```bash +az containerapp update -n -g +``` + **Key Vault soft-delete conflict** If you destroy and recreate the stack, Azure retains the Key Vault in a soft-deleted state for 7 days. Either purge it manually (`az keyvault purge --name `) or use a different `environment_name`. diff --git a/main.tf b/main.tf index a06096e..63b8386 100644 --- a/main.tf +++ b/main.tf @@ -6,6 +6,10 @@ terraform { source = "hashicorp/azurerm" version = "~> 3.80" } + tls = { + source = "hashicorp/tls" + version = "~> 4.0" + } } } @@ -134,9 +138,56 @@ resource "azurerm_key_vault_secret" "socket_api_token" { depends_on = [azurerm_role_assignment.kv_secrets_officer] } +# ── Self-signed TLS certificate (optional) ────────────────────────────────── +# When generate_self_signed_cert = true, creates a server cert with SANs matching +# the domain variable. This covers common setups where the firewall sits behind +# a load balancer (Azure Front Door, Application Gateway, etc.) that terminates +# the public TLS and re-encrypts to the Container App. +# +# For Front Door with private link + certificate subject name validation, the +# cert must include a SAN matching the origin host header. Add extra hostnames +# (e.g., the Front Door endpoint FQDN) to the domain variable separated by spaces. + +resource "tls_private_key" "server" { + count = var.generate_self_signed_cert ? 1 : 0 + algorithm = "RSA" + rsa_bits = 2048 +} + +resource "tls_self_signed_cert" "server" { + count = var.generate_self_signed_cert ? 1 : 0 + private_key_pem = tls_private_key.server[0].private_key_pem + + subject { + common_name = split(" ", var.domain)[0] + organization = "Socket Firewall (${local.env_name})" + } + + # Include all space-separated hostnames from the domain variable as SANs, + # plus "localhost" for in-container testing. + dns_names = concat( + [for d in split(" ", var.domain) : d if d != "localhost"], + ["localhost"] + ) + + validity_period_hours = 87600 # 10 years + is_ca_certificate = false + + allowed_uses = [ + "key_encipherment", + "digital_signature", + "server_auth", + ] +} + +locals { + ssl_cert_pem = var.generate_self_signed_cert ? tls_self_signed_cert.server[0].cert_pem : var.ssl_cert + ssl_key_pem = var.generate_self_signed_cert ? tls_private_key.server[0].private_key_pem : var.ssl_key +} + resource "azurerm_key_vault_secret" "ssl_cert" { name = "ssl-cert" - value = var.ssl_cert + value = local.ssl_cert_pem key_vault_id = azurerm_key_vault.this.id depends_on = [azurerm_role_assignment.kv_secrets_officer] @@ -144,7 +195,7 @@ resource "azurerm_key_vault_secret" "ssl_cert" { resource "azurerm_key_vault_secret" "ssl_key" { name = "ssl-key" - value = var.ssl_key + value = local.ssl_key_pem key_vault_id = azurerm_key_vault.this.id depends_on = [azurerm_role_assignment.kv_secrets_officer] @@ -264,6 +315,27 @@ resource "azurerm_container_app" "firewall" { value = tostring(var.redis_port) } + # Firewall behavior env vars (must be set as env vars, not just in socket.yml) + env { + name = "SOCKET_FAIL_OPEN" + value = tostring(var.socket_fail_open) + } + + env { + name = "SOCKET_LOG_LEVEL" + value = var.log_level + } + + env { + name = "SOCKET_DEBUG_LOGGING_ENABLED" + value = tostring(var.debug_logging_enabled) + } + + env { + name = "SOCKET_DEBUG_USER_AGENT_FILTER" + value = var.debug_user_agent_filter + } + # ── Volume mounts ───────────────────────────────────────────────── # Secret volumes project each secret as a file named after the secret. # The container's entrypoint or an init script should copy/symlink: diff --git a/outputs.tf b/outputs.tf index fa0974a..cc6c689 100644 --- a/outputs.tf +++ b/outputs.tf @@ -27,3 +27,45 @@ output "managed_identity_client_id" { description = "Client ID of the user-assigned managed identity" value = azurerm_user_assigned_identity.this.client_id } + +output "ssl_cert_sans" { + description = "Subject Alternative Names on the SSL certificate (for verifying Front Door cert validation)" + value = var.generate_self_signed_cert ? tls_self_signed_cert.server[0].dns_names : ["(using provided cert)"] +} + +output "troubleshooting" { + description = "Useful commands for debugging the firewall deployment" + value = <<-EOT + + # ── View container logs (real-time) ────────────────────────────── + az containerapp logs show -n ${azurerm_container_app.firewall.name} -g ${azurerm_resource_group.this.name} --type console --follow + + # ── Force a new revision (picks up new secrets/env vars) ───────── + az containerapp update -n ${azurerm_container_app.firewall.name} -g ${azurerm_resource_group.this.name} + + # ── Open a console session ─────────────────────────────────────── + az containerapp exec -n ${azurerm_container_app.firewall.name} -g ${azurerm_resource_group.this.name} + + # ── Verify cert SANs inside container ──────────────────────────── + # (run from console) + openssl x509 -in /etc/nginx/ssl/fullchain.pem -noout -subject -ext subjectAltName + + # ── Check nginx config ─────────────────────────────────────────── + # (run from console) + grep server_name /app/sites-enabled/path-routing.conf + cat /app/nginx.conf | grep error_log + + # ── Test health endpoint from inside container ─────────────────── + # (run from console) + curl -sk https://localhost:8443/health + + # ── Test npm route from inside container ───────────────────────── + # (run from console) + curl -sk https://localhost:8443/npm/lodash | head -c 200 + + # ── Check tarball URL rewriting ────────────────────────────────── + # (run from console) Tarball URLs should use your domain, not the Container App FQDN + curl -sk https://localhost:8443/npm/lodash | grep -o '"tarball":"[^"]*"' | head -3 + + EOT +} diff --git a/terraform.tfvars.example b/terraform.tfvars.example index 40e9765..607448b 100644 --- a/terraform.tfvars.example +++ b/terraform.tfvars.example @@ -8,6 +8,11 @@ environment_name = "socket-fw" # Hostname clients use to reach the firewall. # After first deploy, set to the FQDN from terraform output, or use your custom DNS name. +# +# IMPORTANT: If you use Azure Front Door with private link and certificate subject +# name validation, include ALL hostnames that need to match the cert's SANs. +# Separate multiple hostnames with spaces: +# domain = "registry.company.com ca-socket-fw.happystone-xxxx.eastus.azurecontainerapps.io" domain = "registry.company.com" # VNet and subnet for internal networking. @@ -33,17 +38,23 @@ registries = { socket_fail_open = true -# Debug logging (optional, useful for troubleshooting) +# Logging +# log_level = "debug" # error, warn, info, debug. Use "debug" to see TLS handshakes and full request details. + +# Debug logging (logs full HTTP request/response headers for matching requests) # debug_logging_enabled = true -# debug_user_agent_filter = "pip" # glob pattern, case-insensitive +# debug_user_agent_filter = "npm*" # glob pattern, case-insensitive. Empty = log all. # Recently published package enforcement (optional) # Blocks packages published within the last 72 hours for the listed ecosystems. # recently_published_enabled_ecosystems = ["npm"] -# SSL certificates (base64-encoded PEM, set via TF_VAR_ env vars for CI/CD) -# ssl_cert = "" -# ssl_key = "" +# SSL certificates +# By default, a self-signed cert is generated with SANs matching the domain variable. +# Set generate_self_signed_cert = false to provide your own cert/key. +# generate_self_signed_cert = false +# ssl_cert = "" # PEM-encoded certificate (set via TF_VAR_ssl_cert env var for CI/CD) +# ssl_key = "" # PEM-encoded private key (set via TF_VAR_ssl_key env var for CI/CD) # Redis (optional) redis_enabled = false diff --git a/variables.tf b/variables.tf index d8c977a..014ebad 100644 --- a/variables.tf +++ b/variables.tf @@ -49,7 +49,18 @@ variable "domain" { type = string } -# ── Debug Logging ───────────────────────────────────────────────────────────── +# ── Logging ─────────────────────────────────────────────────────────────────── + +variable "log_level" { + description = "Firewall log level: error, warn, info, debug. Debug shows TLS handshakes and full request details in nginx error log." + type = string + default = "info" + + validation { + condition = contains(["error", "warn", "info", "debug"], var.log_level) + error_message = "log_level must be one of: error, warn, info, debug" + } +} variable "debug_logging_enabled" { description = "Enable debug logging for HTTP requests and responses" @@ -73,16 +84,24 @@ variable "recently_published_enabled_ecosystems" { # ── SSL ────────────────────────────────────────────────────────────────────── +variable "generate_self_signed_cert" { + description = "Generate a self-signed TLS certificate with SANs matching the domain variable. Set to false and provide ssl_cert/ssl_key to use your own certificate." + type = bool + default = true +} + variable "ssl_cert" { - description = "PEM-encoded SSL certificate for the firewall (base64-encoded)" + description = "PEM-encoded SSL certificate (ignored when generate_self_signed_cert = true)" type = string sensitive = true + default = "" } variable "ssl_key" { - description = "PEM-encoded SSL private key for the firewall (base64-encoded)" + description = "PEM-encoded SSL private key (ignored when generate_self_signed_cert = true)" type = string sensitive = true + default = "" } # ── Redis (optional) ──────────────────────────────────────────────────────── From 036b7d9102695277b2a8d4da0a6d2327faeb74d6 Mon Sep 17 00:00:00 2001 From: David Larsen Date: Wed, 25 Mar 2026 12:48:15 -0400 Subject: [PATCH 09/14] Add Container App custom domain binding for Front Door origin host header (#1) The Container Apps ingress rejects requests with Host headers that don't match the default FQDN. When Front Door sends Host: , the ingress returns 404 before nginx sees the request. This adds: - tls_pkcs12_archive to convert the self-signed cert to PFX format - azurerm_container_app_environment_certificate to register the cert - azurerm_container_app_custom_domain for each hostname in the domain var All three layers of hostname matching are now handled by the template: Container Apps ingress, Front Door cert validation, and nginx server_name. --- README.md | 2 ++ main.tf | 47 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fe44d63..17854b5 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,8 @@ If you destroy and recreate the stack, Azure retains the Key Vault in a soft-del ## Notes +**Custom domain binding**: When `generate_self_signed_cert = true`, the template automatically registers each hostname in the `domain` variable as a custom domain on the Container App. This is required so the Container Apps ingress accepts requests with those Host headers. Without it, requests from Front Door (or any client using a custom hostname) get a 404 from the ingress layer before reaching nginx. + Azure Container Apps mounts secrets as files in a shared volume at `/mnt/config/`. The template sets the `CONFIG_FILE` env var so the firewall reads socket.yml from the correct path. SSL certificate paths in the generated `socket.yml` reference `/mnt/config/ssl-cert` and `/mnt/config/ssl-key`. The `socket.yml` config is auto-generated from the `registries` and `domain` variables. You do not need to write or encode it manually. diff --git a/main.tf b/main.tf index 63b8386..79a58d8 100644 --- a/main.tf +++ b/main.tf @@ -180,9 +180,19 @@ resource "tls_self_signed_cert" "server" { ] } +resource "tls_pkcs12_archive" "server" { + count = var.generate_self_signed_cert ? 1 : 0 + cert_pem = tls_self_signed_cert.server[0].cert_pem + private_key_pem = tls_private_key.server[0].private_key_pem + password = "" +} + locals { ssl_cert_pem = var.generate_self_signed_cert ? tls_self_signed_cert.server[0].cert_pem : var.ssl_cert ssl_key_pem = var.generate_self_signed_cert ? tls_private_key.server[0].private_key_pem : var.ssl_key + + # Custom domains: all hostnames from the domain variable except "localhost" + custom_domains = [for d in split(" ", var.domain) : d if d != "localhost"] } resource "azurerm_key_vault_secret" "ssl_cert" { @@ -211,12 +221,27 @@ resource "azurerm_container_app_environment" "this" { infrastructure_subnet_id = var.subnet_id internal_load_balancer_enabled = true tags = var.tags + + lifecycle { + ignore_changes = [infrastructure_resource_group_name] + } } -# ── Container Apps Environment Storage (socket.yml) ───────────────────────── -# Azure Container Apps supports Azure Files for volume mounts. For the config -# file and SSL certs we use Container App secrets + volume mounts of type -# "Secret", which are projected as files inside the container. +# ── Custom Domain + Certificate Binding ────────────────────────────────────── +# Registers the TLS certificate with the Container Apps Environment and binds +# each custom domain (from the domain variable) to the Container App. +# Without this, the Container Apps ingress rejects requests with Host headers +# that don't match the default FQDN, returning 404 before nginx ever sees them. +# This is required when Azure Front Door sends the custom domain as the origin +# host header (which it must, so tarball URLs are rewritten correctly). + +resource "azurerm_container_app_environment_certificate" "server" { + count = var.generate_self_signed_cert ? 1 : 0 + name = "cert-${local.env_name}" + container_app_environment_id = azurerm_container_app_environment.this.id + certificate_blob_base64 = tls_pkcs12_archive.server[0].content_base64 + certificate_password = "" +} # ── Container App ──────────────────────────────────────────────────────────── @@ -396,3 +421,17 @@ resource "azurerm_container_app" "firewall" { } } } + +# ── Custom Domain Bindings ───────────────────────────────────────────────── +# Bind each custom domain to the Container App so the ingress accepts requests +# with those Host headers. Without this, Front Door gets 404 when it sends +# Host: to the Container App. + +resource "azurerm_container_app_custom_domain" "domains" { + for_each = var.generate_self_signed_cert ? toset(local.custom_domains) : toset([]) + + name = each.value + container_app_id = azurerm_container_app.firewall.id + container_app_environment_certificate_id = azurerm_container_app_environment_certificate.server[0].id + certificate_binding_type = "SniEnabled" +} From 91ddad4fd0c5d40b7732117ffbf3dc387b7d344a Mon Sep 17 00:00:00 2001 From: David Larsen Date: Wed, 10 Jun 2026 18:03:44 -0400 Subject: [PATCH 10/14] Add Redis support and out-of-band Key Vault secret references (#2) Adds Redis auth and TLS for Azure Cache for Redis: redis_password (Key Vault-backed REDIS_PASSWORD secret) and redis_ssl (REDIS_SSL, default true). No-ops while redis_enabled = false. Adds *_key_vault_secret_id variants for socket_api_token, ssl_cert/ssl_key, and redis_password so secrets can be created out-of-band and referenced by ID, keeping values out of Terraform state. Preconditions enforce token presence and block SSL references with self-signed generation. moved blocks keep existing deployments from recreating their Key Vault secrets. Also fixes terraform validate on fresh clones: tls_pkcs12_archive does not exist in hashicorp/tls; replaced with chilicat/pkcs12 pkcs12_from_pem. --- README.md | 2 + main.tf | 135 +++++++++++++++++++++++++++++++-------- terraform.tfvars.example | 31 +++++++-- variables.tf | 40 +++++++++++- 4 files changed, 177 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 17854b5..0c221c5 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Key variables: - `debug_logging_enabled` - Enable debug logging for HTTP requests/responses (default: false) - `debug_user_agent_filter` - Glob pattern to filter debug logs by user-agent (default: "") - `recently_published_enabled_ecosystems` - Ecosystems to enforce recently-published blocking (default: []) +- `redis_enabled` / `redis_host` / `redis_port` / `redis_password` / `redis_ssl` - Optional shared cache across replicas (default: disabled). For Azure Cache for Redis: use cluster mode disabled (Basic/Standard, or Premium with clustering off), port 6380, `redis_ssl = true`, and set `redis_password` to an access key. Redis failures fail safe to each replica's local cache. +- `socket_api_token_key_vault_secret_id` / `ssl_cert_key_vault_secret_id` / `ssl_key_key_vault_secret_id` / `redis_password_key_vault_secret_id` - Reference existing Key Vault secrets created out-of-band instead of passing values through Terraform. Secret values passed directly (`socket_api_token`, `ssl_cert`/`ssl_key`, `redis_password`) are persisted in Terraform state even though they are marked sensitive; the `*_key_vault_secret_id` variants keep them out of Terraform entirely. The Container App managed identity needs the Key Vault Secrets User role on the vault holding referenced secrets. Note: with `generate_self_signed_cert = true` (the default), the private key is generated by Terraform and stored in state regardless; bring your own certificate via the KV references if state must stay free of key material. Either way, protect the state file itself (remote backend with access control). ## Registries diff --git a/main.tf b/main.tf index 79a58d8..f54b9f3 100644 --- a/main.tf +++ b/main.tf @@ -10,6 +10,10 @@ terraform { source = "hashicorp/tls" version = "~> 4.0" } + pkcs12 = { + source = "chilicat/pkcs12" + version = "~> 0.2" + } } } @@ -130,7 +134,11 @@ resource "azurerm_role_assignment" "kv_secrets_officer" { principal_id = data.azurerm_client_config.current.object_id } +# Only created when the token is passed directly. With +# socket_api_token_key_vault_secret_id, the secret is managed out-of-band and +# the value never enters Terraform or its state. resource "azurerm_key_vault_secret" "socket_api_token" { + count = var.socket_api_token != "" && var.socket_api_token_key_vault_secret_id == "" ? 1 : 0 name = "socket-api-token" value = var.socket_api_token key_vault_id = azurerm_key_vault.this.id @@ -138,6 +146,32 @@ resource "azurerm_key_vault_secret" "socket_api_token" { depends_on = [azurerm_role_assignment.kv_secrets_officer] } +# Preserve existing deployments now that the secret resources use count +moved { + from = azurerm_key_vault_secret.socket_api_token + to = azurerm_key_vault_secret.socket_api_token[0] +} + +locals { + socket_api_token_secret_id = var.socket_api_token_key_vault_secret_id != "" ? var.socket_api_token_key_vault_secret_id : (var.socket_api_token != "" ? azurerm_key_vault_secret.socket_api_token[0].versionless_id : "") +} + +# Only created when the password is passed directly. With +# redis_password_key_vault_secret_id, the secret is managed out-of-band and +# the value never enters Terraform or its state. +resource "azurerm_key_vault_secret" "redis_password" { + count = var.redis_password != "" && var.redis_password_key_vault_secret_id == "" ? 1 : 0 + name = "redis-password" + value = var.redis_password + key_vault_id = azurerm_key_vault.this.id + + depends_on = [azurerm_role_assignment.kv_secrets_officer] +} + +locals { + redis_password_secret_id = var.redis_password_key_vault_secret_id != "" ? var.redis_password_key_vault_secret_id : (var.redis_password != "" ? azurerm_key_vault_secret.redis_password[0].versionless_id : "") +} + # ── Self-signed TLS certificate (optional) ────────────────────────────────── # When generate_self_signed_cert = true, creates a server cert with SANs matching # the domain variable. This covers common setups where the firewall sits behind @@ -180,11 +214,11 @@ resource "tls_self_signed_cert" "server" { ] } -resource "tls_pkcs12_archive" "server" { - count = var.generate_self_signed_cert ? 1 : 0 - cert_pem = tls_self_signed_cert.server[0].cert_pem - private_key_pem = tls_private_key.server[0].private_key_pem - password = "" +resource "pkcs12_from_pem" "server" { + count = var.generate_self_signed_cert ? 1 : 0 + cert_pem = tls_self_signed_cert.server[0].cert_pem + private_key_pem = tls_private_key.server[0].private_key_pem + password = "" } locals { @@ -196,6 +230,7 @@ locals { } resource "azurerm_key_vault_secret" "ssl_cert" { + count = var.ssl_cert_key_vault_secret_id == "" ? 1 : 0 name = "ssl-cert" value = local.ssl_cert_pem key_vault_id = azurerm_key_vault.this.id @@ -204,6 +239,7 @@ resource "azurerm_key_vault_secret" "ssl_cert" { } resource "azurerm_key_vault_secret" "ssl_key" { + count = var.ssl_key_key_vault_secret_id == "" ? 1 : 0 name = "ssl-key" value = local.ssl_key_pem key_vault_id = azurerm_key_vault.this.id @@ -211,6 +247,22 @@ resource "azurerm_key_vault_secret" "ssl_key" { depends_on = [azurerm_role_assignment.kv_secrets_officer] } +# Preserve existing deployments now that the secret resources use count +moved { + from = azurerm_key_vault_secret.ssl_cert + to = azurerm_key_vault_secret.ssl_cert[0] +} + +moved { + from = azurerm_key_vault_secret.ssl_key + to = azurerm_key_vault_secret.ssl_key[0] +} + +locals { + ssl_cert_secret_id = var.ssl_cert_key_vault_secret_id != "" ? var.ssl_cert_key_vault_secret_id : azurerm_key_vault_secret.ssl_cert[0].versionless_id + ssl_key_secret_id = var.ssl_key_key_vault_secret_id != "" ? var.ssl_key_key_vault_secret_id : azurerm_key_vault_secret.ssl_key[0].versionless_id +} + # ── Container Apps Environment ─────────────────────────────────────────────── resource "azurerm_container_app_environment" "this" { @@ -239,7 +291,7 @@ resource "azurerm_container_app_environment_certificate" "server" { count = var.generate_self_signed_cert ? 1 : 0 name = "cert-${local.env_name}" container_app_environment_id = azurerm_container_app_environment.this.id - certificate_blob_base64 = tls_pkcs12_archive.server[0].content_base64 + certificate_blob_base64 = pkcs12_from_pem.server[0].result certificate_password = "" } @@ -257,23 +309,34 @@ resource "azurerm_container_app" "firewall" { identity_ids = [azurerm_user_assigned_identity.this.id] } + lifecycle { + precondition { + condition = var.socket_api_token != "" || var.socket_api_token_key_vault_secret_id != "" + error_message = "Set socket_api_token or socket_api_token_key_vault_secret_id." + } + precondition { + condition = !(var.generate_self_signed_cert && (var.ssl_cert_key_vault_secret_id != "" || var.ssl_key_key_vault_secret_id != "")) + error_message = "ssl_cert_key_vault_secret_id and ssl_key_key_vault_secret_id require generate_self_signed_cert = false." + } + } + # ── Secrets (pulled from Key Vault via managed identity) ───────────────── secret { name = "socket-api-token" - key_vault_secret_id = azurerm_key_vault_secret.socket_api_token.versionless_id + key_vault_secret_id = local.socket_api_token_secret_id identity = azurerm_user_assigned_identity.this.id } secret { name = "ssl-cert" - key_vault_secret_id = azurerm_key_vault_secret.ssl_cert.versionless_id + key_vault_secret_id = local.ssl_cert_secret_id identity = azurerm_user_assigned_identity.this.id } secret { name = "ssl-key" - key_vault_secret_id = azurerm_key_vault_secret.ssl_key.versionless_id + key_vault_secret_id = local.ssl_key_secret_id identity = azurerm_user_assigned_identity.this.id } @@ -282,6 +345,15 @@ resource "azurerm_container_app" "firewall" { value = local.socket_yml } + dynamic "secret" { + for_each = local.redis_password_secret_id != "" ? [1] : [] + content { + name = "redis-password" + key_vault_secret_id = local.redis_password_secret_id + identity = azurerm_user_assigned_identity.this.id + } + } + # ── Ingress (internal only) ───────────────────────────────────────────── ingress { @@ -340,6 +412,19 @@ resource "azurerm_container_app" "firewall" { value = tostring(var.redis_port) } + dynamic "env" { + for_each = local.redis_password_secret_id != "" ? [1] : [] + content { + name = "REDIS_PASSWORD" + secret_name = "redis-password" + } + } + + env { + name = "REDIS_SSL" + value = tostring(var.redis_ssl) + } + # Firewall behavior env vars (must be set as env vars, not just in socket.yml) env { name = "SOCKET_FAIL_OPEN" @@ -376,23 +461,23 @@ resource "azurerm_container_app" "firewall" { # ── Liveness probe ──────────────────────────────────────────────── liveness_probe { - transport = "HTTPS" - port = 8443 - path = "/health" - initial_delay = 15 - interval_seconds = 30 - timeout = 5 + transport = "HTTPS" + port = 8443 + path = "/health" + initial_delay = 15 + interval_seconds = 30 + timeout = 5 failure_count_threshold = 3 } # ── Readiness probe ─────────────────────────────────────────────── readiness_probe { - transport = "HTTPS" - port = 8443 - path = "/health" - interval_seconds = 10 - timeout = 3 + transport = "HTTPS" + port = 8443 + path = "/health" + interval_seconds = 10 + timeout = 3 failure_count_threshold = 3 success_count_threshold = 1 } @@ -400,11 +485,11 @@ resource "azurerm_container_app" "firewall" { # ── Startup probe ───────────────────────────────────────────────── startup_probe { - transport = "HTTPS" - port = 8443 - path = "/health" - interval_seconds = 5 - timeout = 3 + transport = "HTTPS" + port = 8443 + path = "/health" + interval_seconds = 5 + timeout = 3 failure_count_threshold = 10 } } diff --git a/terraform.tfvars.example b/terraform.tfvars.example index 607448b..56a8611 100644 --- a/terraform.tfvars.example +++ b/terraform.tfvars.example @@ -3,8 +3,13 @@ location = "eastus" resource_group_name = "rg-socket-firewall" environment_name = "socket-fw" -# Socket API token (set via TF_VAR_socket_api_token env var or -var flag) +# Socket API token (set via TF_VAR_socket_api_token env var or -var flag; +# note it is persisted in Terraform state) # socket_api_token = "" +# +# To keep the token out of Terraform state entirely, create the Key Vault +# secret out-of-band (az keyvault secret set ...) and reference it instead: +# socket_api_token_key_vault_secret_id = "https://.vault.azure.net/secrets/" # Hostname clients use to reach the firewall. # After first deploy, set to the FQDN from terraform output, or use your custom DNS name. @@ -51,15 +56,31 @@ socket_fail_open = true # SSL certificates # By default, a self-signed cert is generated with SANs matching the domain variable. +# Note: the generated private key lives in Terraform state. # Set generate_self_signed_cert = false to provide your own cert/key. # generate_self_signed_cert = false # ssl_cert = "" # PEM-encoded certificate (set via TF_VAR_ssl_cert env var for CI/CD) -# ssl_key = "" # PEM-encoded private key (set via TF_VAR_ssl_key env var for CI/CD) +# ssl_key = "" # PEM-encoded private key (set via TF_VAR_ssl_key env var for CI/CD; +# # note it is persisted in Terraform state) +# +# To keep cert material out of Terraform state entirely, create the Key Vault +# secrets out-of-band and reference them (requires generate_self_signed_cert = false): +# ssl_cert_key_vault_secret_id = "https://.vault.azure.net/secrets/" +# ssl_key_key_vault_secret_id = "https://.vault.azure.net/secrets/" -# Redis (optional) +# Redis (optional) -- shared cache across replicas +# Use Azure Cache for Redis with cluster mode disabled (Basic/Standard, or +# Premium with clustering off). Deploy it in the same region as the firewall. redis_enabled = false -# redis_host = "redis.internal.example.com" -# redis_port = 6379 +# redis_host = ".redis.cache.windows.net" +# redis_port = 6380 +# redis_password = "" # Azure Cache access key (set via TF_VAR_redis_password for CI/CD; +# # note it is persisted in Terraform state) +# redis_ssl = true # Azure Cache requires TLS on port 6380 +# +# To keep the password out of Terraform state entirely, create the Key Vault +# secret out-of-band (az keyvault secret set ...) and reference it instead: +# redis_password_key_vault_secret_id = "https://.vault.azure.net/secrets/" # Scaling firewall_image = "socketdev/socket-registry-firewall:latest" diff --git a/variables.tf b/variables.tf index 014ebad..4605882 100644 --- a/variables.tf +++ b/variables.tf @@ -25,9 +25,16 @@ variable "firewall_image" { } variable "socket_api_token" { - description = "Socket Security API token" + description = "Socket Security API token. Persisted in Terraform state; to avoid that, use socket_api_token_key_vault_secret_id instead." type = string sensitive = true + default = "" +} + +variable "socket_api_token_key_vault_secret_id" { + description = "ID of an existing Key Vault secret holding the Socket API token (e.g. https://.vault.azure.net/secrets/), created out-of-band so the value never passes through Terraform or its state. Takes precedence over socket_api_token. The Container App managed identity needs the Key Vault Secrets User role on that vault." + type = string + default = "" } variable "socket_fail_open" { @@ -104,6 +111,18 @@ variable "ssl_key" { default = "" } +variable "ssl_cert_key_vault_secret_id" { + description = "ID of an existing Key Vault secret holding the PEM-encoded SSL certificate, created out-of-band. Takes precedence over ssl_cert. Requires generate_self_signed_cert = false." + type = string + default = "" +} + +variable "ssl_key_key_vault_secret_id" { + description = "ID of an existing Key Vault secret holding the PEM-encoded SSL private key, created out-of-band so the key never passes through Terraform or its state. Takes precedence over ssl_key. Requires generate_self_signed_cert = false." + type = string + default = "" +} + # ── Redis (optional) ──────────────────────────────────────────────────────── variable "redis_enabled" { @@ -124,6 +143,25 @@ variable "redis_port" { default = 6379 } +variable "redis_password" { + description = "Redis AUTH password (e.g. Azure Cache for Redis access key). Stored in Key Vault and injected as a secret. Note: the value is also persisted in Terraform state; to avoid that, use redis_password_key_vault_secret_id instead." + type = string + default = "" + sensitive = true +} + +variable "redis_password_key_vault_secret_id" { + description = "ID of an existing Key Vault secret holding the Redis password (e.g. https://.vault.azure.net/secrets/), created out-of-band so the value never passes through Terraform or its state. Takes precedence over redis_password. The Container App managed identity needs the Key Vault Secrets User role on that vault." + type = string + default = "" +} + +variable "redis_ssl" { + description = "Connect to Redis over TLS. Azure Cache for Redis requires TLS on port 6380." + type = bool + default = true +} + # ── Scaling ────────────────────────────────────────────────────────────────── variable "min_replicas" { From f8af31158f7e669c48e8d77c9f3e097ad1ef131c Mon Sep 17 00:00:00 2001 From: David Larsen Date: Sat, 5 Sep 2026 14:49:59 -0400 Subject: [PATCH 11/14] azure-container-apps: make ingress reachable from the VNet external_enabled = false on an internal environment scopes ingress to the Container Apps environment rather than the VNet, so nothing outside the environment (developer machines, CI runners, Front Door) can reach the firewall. target_port 8443 with transport "http" also sent cleartext to the TLS listener. The HTTPS health probe on 8443 still succeeds, so the revision reports healthy while serving no traffic, which is a slow failure to diagnose. Points ingress at the plaintext listener (8080) and scopes it to the VNet. --- terraform/azure-container-apps/main.tf | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/terraform/azure-container-apps/main.tf b/terraform/azure-container-apps/main.tf index f54b9f3..8e7506a 100644 --- a/terraform/azure-container-apps/main.tf +++ b/terraform/azure-container-apps/main.tf @@ -357,9 +357,19 @@ resource "azurerm_container_app" "firewall" { # ── Ingress (internal only) ───────────────────────────────────────────── ingress { - external_enabled = false - target_port = 8443 - transport = "http" + # On an internal environment (internal_load_balancer_enabled = true), + # external_enabled = true scopes ingress to the VNet (portal shows + # "Limited to VNet"). Setting it to false scopes ingress to the Container + # Apps environment only, which leaves the firewall unreachable from + # developer machines, CI runners, jumpboxes, or Front Door. + external_enabled = true + + # 8080 is the plaintext listener (see ports.http in socket_yml above). + # TLS is terminated by Container Apps ingress, so target_port must match + # transport. Pointing "http" transport at 8443 sends cleartext to the + # TLS listener: the revision passes its health probes and serves nothing. + target_port = 8080 + transport = "http" traffic_weight { percentage = 100 From 7a55a0217da4ccf2e302683250f7202575e654fd Mon Sep 17 00:00:00 2001 From: David Larsen Date: Sat, 5 Sep 2026 14:50:12 -0400 Subject: [PATCH 12/14] azure-container-apps: add registry_overrides so non-ecosystem route names stay inspected routes derived its ecosystem from the registries map key. A key that is not a valid ecosystem name (an Artifactory path such as repository/npm-remote, or a route named nuget-v2) is served by the firewall's fallback streaming proxy, which forwards traffic with no package inspection. The deployment looks healthy and packages install normally; the only tell is that no SOCKET_DECISION lines appear in the logs. The README's own Artifactory example produced exactly this shape. registry_overrides maps such route names onto a real ecosystem. --- terraform/azure-container-apps/main.tf | 2 +- terraform/azure-container-apps/variables.tf | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/terraform/azure-container-apps/main.tf b/terraform/azure-container-apps/main.tf index 8e7506a..c39d2c8 100644 --- a/terraform/azure-container-apps/main.tf +++ b/terraform/azure-container-apps/main.tf @@ -42,7 +42,7 @@ locals { routes = [for name, upstream in var.registries : { path = "/${name}" upstream = upstream - registry = name + registry = lookup(var.registry_overrides, name, name) }] socket_yml = yamlencode(merge( diff --git a/terraform/azure-container-apps/variables.tf b/terraform/azure-container-apps/variables.tf index 4605882..16af525 100644 --- a/terraform/azure-container-apps/variables.tf +++ b/terraform/azure-container-apps/variables.tf @@ -51,6 +51,25 @@ variable "registries" { } } +variable "registry_overrides" { + description = <<-EOT + Map of `registries` key to the firewall's ecosystem type, for routes whose + path name is not itself a valid ecosystem. Example: + { "repository/npm-remote" = "npm", "plugins-gradle" = "maven" }. + + Unlisted routes fall back to using their `registries` key as the ecosystem. + A route whose ecosystem the firewall does not recognize is served by a + plain streaming proxy with no package inspection, so it passes traffic + through unscanned while appearing to work. The only signal is the absence + of SOCKET_DECISION lines in the logs. Map any non-ecosystem route name here. + + Valid ecosystems: npm, pypi, nuget, maven, cargo, rubygems, go, conda, + openvsx, huggingface. + EOT + type = map(string) + default = {} +} + variable "domain" { description = "Hostname for path-based routing (e.g., registry.company.com). Use the FQDN from the first deploy or your custom DNS name." type = string From ae2dab1bd56e4383d344499089c5f0aa638e2bf4 Mon Sep 17 00:00:00 2001 From: David Larsen Date: Sat, 5 Sep 2026 14:50:29 -0400 Subject: [PATCH 13/14] azure-container-apps: pin the firewall image and fix the Artifactory example Default the image to 2.4.1 rather than :latest. Container Apps resolves a floating tag once at revision creation, so :latest leaves the running version unknowable and lets a later apply roll a different build. 2.4.1 also carries the NuGet search fix from 2.3.0, which affects anyone fronting api.nuget.org. The Artifactory example now sets registry_overrides. As written it produced route names the firewall does not treat as ecosystems, which meant the example itself demonstrated an unscanned passthrough. --- terraform/azure-container-apps/README.md | 10 ++++++++++ terraform/azure-container-apps/variables.tf | 11 +++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/terraform/azure-container-apps/README.md b/terraform/azure-container-apps/README.md index 0c221c5..ae2aaf0 100644 --- a/terraform/azure-container-apps/README.md +++ b/terraform/azure-container-apps/README.md @@ -76,6 +76,16 @@ registries = { "repository/npm-remote" = "https://company.jfrog.io/artifactory/api/npm/npm-remote" "repository/pypi-remote" = "https://company.jfrog.io/artifactory/api/pypi/pypi-remote" } + +# Required whenever a route name is not itself an ecosystem name. +# Without these, the firewall does not recognize "repository/npm-remote" as an +# ecosystem and serves the route through a plain streaming proxy, forwarding +# packages with no inspection. Installs succeed and the deployment looks +# healthy; the only signal is that no SOCKET_DECISION lines appear in the logs. +registry_overrides = { + "repository/npm-remote" = "npm" + "repository/pypi-remote" = "pypi" +} ``` ```bash diff --git a/terraform/azure-container-apps/variables.tf b/terraform/azure-container-apps/variables.tf index 16af525..c8359a2 100644 --- a/terraform/azure-container-apps/variables.tf +++ b/terraform/azure-container-apps/variables.tf @@ -19,9 +19,16 @@ variable "environment_name" { # ── Socket Firewall ────────────────────────────────────────────────────────── variable "firewall_image" { - description = "Docker image for the Socket Registry Firewall" + description = <<-EOT + Docker image for the Socket Registry Firewall. Pin an explicit version. + + Container Apps resolves a floating tag once, when it creates the revision, + and does not re-resolve it. A `:latest` deployment therefore has no + reliable answer to "which version is running", and a later `terraform + apply` can silently roll a different build. + EOT type = string - default = "socketdev/socket-registry-firewall:latest" + default = "socketdev/socket-registry-firewall:2.4.1" } variable "socket_api_token" { From 97a2bef07fbc9b42c9996449d91193c352aa5c5f Mon Sep 17 00:00:00 2001 From: David Larsen Date: Sat, 5 Sep 2026 14:51:17 -0400 Subject: [PATCH 14/14] azure-container-apps: pin the example image, flag registry_overrides and Key Vault naming Adds the registry_overrides example alongside the Artifactory registries block, since that is where the omission causes an unscanned route. Notes that environment_name derives a globally unique Key Vault name, which is a common first-apply failure, and that Azure Cache auth must be an access key on the default user rather than a Redis 6 ACL user. Also records terraform/ in the repository layout. --- README.md | 3 +++ .../terraform.tfvars.example | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 33ded34..f57bd4b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ Deployment templates for the Socket Registry Firewall. - `helm/` — Kubernetes Helm chart (migrated from `socketdev-demo/socket-firewall-helm` with full commit history). - `cloudformation/` — AWS CloudFormation templates (in progress). +- `terraform/` — Terraform templates. + - `terraform/azure-container-apps/` — Azure Container Apps (migrated from + `socketdev-demo/socket-firewall-azure-container-apps` with full commit history). ## Helm chart publishing diff --git a/terraform/azure-container-apps/terraform.tfvars.example b/terraform/azure-container-apps/terraform.tfvars.example index 56a8611..6111936 100644 --- a/terraform/azure-container-apps/terraform.tfvars.example +++ b/terraform/azure-container-apps/terraform.tfvars.example @@ -1,6 +1,10 @@ # Required location = "eastus" resource_group_name = "rg-socket-firewall" +# environment_name also derives the Key Vault name (kv-). +# Key Vault names are globally unique across Azure and capped at 24 characters, +# and a deleted vault holds its name for the soft-delete retention window. +# Pick something org-specific rather than the default. environment_name = "socket-fw" # Socket API token (set via TF_VAR_socket_api_token env var or -var flag; @@ -40,6 +44,13 @@ registries = { # "repository/npm-remote" = "https://company.jfrog.io/artifactory/api/npm/npm-remote" # "repository/pypi-remote" = "https://company.jfrog.io/artifactory/api/pypi/pypi-remote" # } +# +# Any route name that is not itself an ecosystem name MUST be mapped here, or +# the firewall serves that route as a plain proxy with no package inspection. +# registry_overrides = { +# "repository/npm-remote" = "npm" +# "repository/pypi-remote" = "pypi" +# } socket_fail_open = true @@ -75,7 +86,9 @@ redis_enabled = false # redis_host = ".redis.cache.windows.net" # redis_port = 6380 # redis_password = "" # Azure Cache access key (set via TF_VAR_redis_password for CI/CD; -# # note it is persisted in Terraform state) +# # note it is persisted in Terraform state). +# # Must be an access key on the default user. Redis 6+ +# # ACL username/password pairs are not supported. # redis_ssl = true # Azure Cache requires TLS on port 6380 # # To keep the password out of Terraform state entirely, create the Key Vault @@ -83,7 +96,7 @@ redis_enabled = false # redis_password_key_vault_secret_id = "https://.vault.azure.net/secrets/" # Scaling -firewall_image = "socketdev/socket-registry-firewall:latest" +firewall_image = "socketdev/socket-registry-firewall:2.4.1" min_replicas = 2 max_replicas = 10 cpu = 1.0