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/.gitignore b/terraform/azure-container-apps/.gitignore new file mode 100644 index 0000000..b47dc95 --- /dev/null +++ b/terraform/azure-container-apps/.gitignore @@ -0,0 +1,7 @@ +.terraform/ +*.tfstate +*.tfstate.backup +.terraform.lock.hcl +tfplan* +terraform.tfvars +.DS_Store diff --git a/terraform/azure-container-apps/README.md b/terraform/azure-container-apps/README.md new file mode 100644 index 0000000..ae2aaf0 --- /dev/null +++ b/terraform/azure-container-apps/README.md @@ -0,0 +1,175 @@ +# 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, 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) +- `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 + +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" + pypi = "https://pypi.org" + maven = "https://repo1.maven.org/maven2" +} +``` + +```bash +npm config set registry https://registry.company.com/npm +pip install --index-url https://registry.company.com/pypi/simple +``` + +### 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" +} + +# 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 +npm config set registry https://registry.company.com/repository/npm-remote +pip install --index-url https://registry.company.com/repository/pypi-remote/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 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 a safe package +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 peacenotwar@9.1.3 --registry https:///npm --prefer-online +# Expected: E403 "Blocked by Security Policy" +``` + +## 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 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`. + +## 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. + +## 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) diff --git a/terraform/azure-container-apps/main.tf b/terraform/azure-container-apps/main.tf new file mode 100644 index 0000000..c39d2c8 --- /dev/null +++ b/terraform/azure-container-apps/main.tf @@ -0,0 +1,532 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.80" + } + tls = { + source = "hashicorp/tls" + version = "~> 4.0" + } + pkcs12 = { + source = "chilicat/pkcs12" + version = "~> 0.2" + } + } +} + +provider "azurerm" { + features { + key_vault { + purge_soft_delete_on_destroy = false + } + } +} + +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 = lookup(var.registry_overrides, name, name) + }] + + 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 ─────────────────────────────────────────────────────────── + +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 +} + +# 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 + + 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 +# 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", + ] +} + +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 { + 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" { + 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 + + depends_on = [azurerm_role_assignment.kv_secrets_officer] +} + +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 + + 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" { + 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 + + lifecycle { + ignore_changes = [infrastructure_resource_group_name] + } +} + +# ── 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 = pkcs12_from_pem.server[0].result + certificate_password = "" +} + +# ── 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] + } + + 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 = local.socket_api_token_secret_id + identity = azurerm_user_assigned_identity.this.id + } + + secret { + name = "ssl-cert" + key_vault_secret_id = local.ssl_cert_secret_id + identity = azurerm_user_assigned_identity.this.id + } + + secret { + name = "ssl-key" + key_vault_secret_id = local.ssl_key_secret_id + identity = azurerm_user_assigned_identity.this.id + } + + secret { + name = "socket-yml" + 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 { + # 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 + 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 = "REDIS_ENABLED" + value = tostring(var.redis_enabled) + } + + env { + name = "REDIS_HOST" + value = var.redis_host + } + + env { + name = "REDIS_PORT" + 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" + 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: + # /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 = 10 + } + } + + # ── Scaling rule (CPU-based) ────────────────────────────────────────── + + custom_scale_rule { + name = "cpu-scaling" + custom_rule_type = "cpu" + metadata = { + type = "Utilization" + value = "70" + } + } + } +} + +# ── 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" +} diff --git a/terraform/azure-container-apps/outputs.tf b/terraform/azure-container-apps/outputs.tf new file mode 100644 index 0000000..cc6c689 --- /dev/null +++ b/terraform/azure-container-apps/outputs.tf @@ -0,0 +1,71 @@ +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 +} + +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/azure-container-apps/terraform.tfvars.example b/terraform/azure-container-apps/terraform.tfvars.example new file mode 100644 index 0000000..6111936 --- /dev/null +++ b/terraform/azure-container-apps/terraform.tfvars.example @@ -0,0 +1,109 @@ +# 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; +# 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. +# +# 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. +# 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/" + +# 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" +# } +# +# 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 + +# 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 = "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 +# 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; +# # 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) -- 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.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). +# # 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 +# 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:2.4.1" +min_replicas = 2 +max_replicas = 10 +cpu = 1.0 +memory = "2Gi" + +tags = { + managed-by = "terraform" + service = "socket-registry-firewall" + environment = "production" +} diff --git a/terraform/azure-container-apps/variables.tf b/terraform/azure-container-apps/variables.tf new file mode 100644 index 0000000..c8359a2 --- /dev/null +++ b/terraform/azure-container-apps/variables.tf @@ -0,0 +1,238 @@ +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 = <<-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:2.4.1" +} + +variable "socket_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" { + description = "Whether the firewall fails open when Socket API is unreachable" + type = bool + default = true +} + +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 "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 +} + +# ── 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" + 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 "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 (ignored when generate_self_signed_cert = true)" + type = string + sensitive = true + default = "" +} + +variable "ssl_key" { + description = "PEM-encoded SSL private key (ignored when generate_self_signed_cert = true)" + type = string + sensitive = true + 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" { + 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 +} + +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" { + 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" + } +}