Skip to content

About

FreeRADIUS on GCE via Terraform — HA 802.1X EAP-TLS for Ubiquiti UniFi WiFi with a self-hosted Smallstep CA (ACME + SCEP)

Topics

Resources

Stars

6 stars

Watchers

1 watching

Forks

Latest commit

 

History

52 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

cloud-8021x

Terraform deployment for cloud-hosted FreeRADIUS servers on Google Cloud, providing RADIUS/802.1X authentication for WiFi networks using certificate-based EAP-TLS. The recommended setup also stands up a co-located self-hosted Smallstep step-ca that issues the client certificates (ACME hardware attestation for eligible Apple devices, SCEP for serial-free Apple BYOD and Windows). Works with any RADIUS-capable access point (Ubiquiti UniFi, Cisco Meraki, etc.) and deploys primary + secondary VMs in separate zones for HA failover.

Architecture

Device (EAP-TLS client cert)
  ├─ Managed Apple with attested serial — ACME device-attest-01
  ├─ Serial-free Apple BYOD — dynamic SCEP + Fleet certificate inventory
  └─ Windows — dynamic SCEP machine certificate + Fleet script inventory
      → WiFi AP (WPA2/WPA3 Enterprise — UniFi, Meraki, etc.)
        → RADIUS (UDP 1812/1813) over internet
          → Primary:   FreeRADIUS on GCE VM (us-east4-a, static public IP)
          → Secondary: FreeRADIUS on GCE VM (us-east4-c, static public IP)
            → EAP-TLS: validates client cert against the configured CA(s)
            → Optional inventory authorization + location-specific VLAN policy
            → Access-Accept → WiFi connected

Self-hosted CA (when enable_smallstep_ca = true), co-located on the RADIUS VMs:
  EC GCLB (HTTPS, Cloud Armor) → step-ca :8443 (attested ACME)
  RSA GCLB (HTTPS, Cloud Armor) → step-ca :8444 (SCEP)
    └─ /fleet/* challenge routes → HTTPS broker :9081 (optional)
  Separate CA chains, KMS HSM-backed intermediates, Cloud SQL Postgres state
  CA authorization → loopback mTLS webhook :9444
  • Authentication: EAP-TLS only (no passwords). Client certificates come from the self-hosted step-ca (ACME for eligible Apple devices, SCEP for Apple BYOD and Windows); Okta Managed Attestation SCEP remains supported for existing/migrating deployments.
  • Trust model: Client validation and server trust are separate settings. radius_trust_mode selects Smallstep, Okta, or both client CA chains. In smallstep/both mode the standard bootstrap presents a Smallstep EC-rooted server certificate; okta mode uses the legacy self-signed RADIUS CA. Stage the matching server trust root on devices before switching. See profile trust roots.
  • Self-hosted CA (optional, recommended): step-ca runs on the RADIUS VMs behind a global HTTPS load balancer + Cloud Armor, with a Cloud KMS–backed intermediate and Cloud SQL for ACME HA state. CA material persists in Secret Manager and is restored on reboot/rebuild (never re-minted on a transient failure).
  • Accounting: FreeRADIUS native SQL module writes to local MariaDB (radacct table).
  • Secrets: All managed via GCP Secret Manager (RADIUS shared secrets, server + CA certs, Datadog API key, Fleet token). Runtime keys and credentials are restored into protected files; some remain on disk. Terraform-managed secret values also exist in Terraform state, which must be protected.
  • Site sources: Match routed/VPN subnets by CIDR, or pin a UniFi gateway host ID to follow public WAN changes automatically. Per-site dynamic_vlans = false keeps device authorization/logging while using the SSID network. See configuration and rollout details.
  • Observability: Datadog Agent for infrastructure metrics + log shipping to SIEM. Prometheus exporters for FreeRADIUS and step-ca metrics. Structured JSON auth/accounting logs via FreeRADIUS linelog, plus step-ca request logs (with real client IP via X-Forwarded-For).
  • Log enrichment: Optional MDM (Fleet or Jamf) and UniFi integrations add device owner, device name, model, AP name, and site name to both auth and accounting JSON logs, resolved from a local cache (no API calls on the auth path). Certificate inventory mode uses verified certificate bindings and stable device IDs, including serial-free BYOD; legacy mode uses serials.

Client Certificate Issuance: Two Trust Modes

This deployment supports two independent paths for client certificate validation:

Recommended: self-hosted Smallstep step-ca

Strongly recommended over Okta SCEP for client certificates. Okta's Managed Attestation SCEP endpoint enforces low, undocumented rate limits — at fleet scale the 429s cause MDM profile installs to fail and certs to never issue, which is hard to diagnose and silently breaks Wi-Fi onboarding. A self-hosted step-ca removes that dependency entirely: you own the issuance path, with deployment-controlled Cloud Armor rate limits, and it supports ACME (hardware attestation on Apple) and SCEP (Apple BYOD and Windows). New deployments should prefer this; the Okta SCEP path remains supported for existing setups and migration.

Set enable_smallstep_ca = true to stand up a self-hosted Smallstep step-ca co-located on the RADIUS VMs. It exposes:

  • ACME (/acme/<name>/directory) with Apple device-attest-01 attestation, fronted by a public GCLB + Cloud Armor. Gate issuance with enable_acme_webhook = true (see webhook setup) — Apple attestation alone proves "a real Apple device", not "one of yours".
  • SCEP on the separate RSA CA (/scep/<name>) for Windows and serial-free Apple BYOD, typically fronted by an MDM's SCEP proxy.

Intermediate CA signing keys live in Cloud KMS HSM. SCEP envelope decryption and response signing use a shared software RSA key stored in Secret Manager. CA state uses regional Cloud SQL Postgres. EC and RSA roots are published as smallstep-ca-cert and smallstep-rsa-root-cert.

RADIUS client validation:

  • "okta" (default): trust the configured Okta CA bundle.
  • "smallstep": trust the self-hosted EC and RSA client CA chains.
  • "both": trust both during migration. This also switches the presented server certificate to Smallstep; dual client trust does not preserve legacy server trust.

Pre-stage client certificates and the matching server trust root/name, then pilot the trust-mode change. Fingerprint enforcement additionally requires inventory coverage on both RADIUS nodes for every supported client.

See example profiles and the Fleet deployment guide. For managed Macs, use System mode for device-certificate authentication before login; System + Login Window mode additionally supports a transition to user credentials.

Prerequisites

  • Terraform >= 1.9
  • GCP account with permissions to create projects and enable billing
  • gcloud CLI authenticated (gcloud auth application-default login)
  • For the Okta SCEP client-cert path (one of two options):
    • Okta Intermediate CA certificate (okta-ca.pem) from your Okta org
    • Okta Managed Attestation configured in Jamf (SCEP profile)
    • ⚠️ Okta's SCEP endpoint is rate-limited and 429s at fleet scale — prefer the self-hosted Smallstep CA (Recommended section above) for new deployments.

Quick Start

# 1. Copy and edit the example tfvars
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars — choose CA/trust mode, billing, office sources and monitoring.
# For Fleet BYOD/Windows, follow docs/scep-identity-binding.md before enforcement.

# 2. Initialize and deploy
terraform init
terraform plan
terraform apply

# 3. Fetch outputs (certs, shared secrets, config)
#    Wait a few minutes for VMs to finish bootstrapping first
./scripts/fetch-outputs.sh

# Outputs are saved to out/:
#   out/radius-ca.cer              — Legacy RADIUS CA only; see trust-root note below
#   out/radius-server.cer          — Legacy server cert (not the Smallstep leaf)
#   out/shared-secret-<office>.txt — Per-office shared secrets
#   out/config.json                — IPs, ports, SSH commands
#   out/README.md                  — Human-readable summary with all values

fetch-outputs.sh exports legacy server certificate secrets. In Smallstep mode, fetch the EC server trust root separately as described in the profile guide. The out/ directory includes secrets; keep it private and out of Git.

What Terraform Creates

Resource Purpose
GCP Project New project with billing, APIs enabled
VPC + Subnet Custom network (10.0.1.0/24)
Static IPs (x2) Public IPs for primary and secondary RADIUS servers
Firewall rules UDP 1812/1813 from office IPs, SSH from IAP
GCE Instances (x2) Primary + secondary in different zones, Debian 12, e2-medium, FreeRADIUS + MariaDB
Service Account Minimal permissions (Secret Manager read/write)
Secret Manager Per-office secrets, CA/server material, integration credentials, and optional challenge/accounting signing keys
Private Cloud Storage bucket Content-addressed startup script; used automatically when the rendered script exceeds GCE's 256 KiB metadata limit
Optional Smallstep services EC/RSA HTTPS load balancers, Cloud Armor, KMS HSM keys, and Cloud SQL Postgres

Secrets in Secret Manager

Secret Populated by Purpose
radius-shared-secret-<office> Terraform Per-office shared secret between APs and RADIUS
okta-ca-cert Terraform Okta Intermediate CA for client cert validation
okta-root-ca-cert Terraform (optional) Okta Root CA for full chain validation
radius-server-ca-key Startup script Self-signed CA private key
radius-server-ca-cert Startup script Self-signed CA certificate (upload to Jamf)
radius-server-key Startup script RADIUS server private key
radius-server-cert Startup script RADIUS server certificate
radius-dh-params Startup script Diffie-Hellman parameters
datadog-api-key Terraform Datadog Agent API key
fleet-api-token Out-of-band (optional) Observer for lookup/ACME authorization; scoped maintainer for certificate collection
radius-smallstep-server-cert / radius-smallstep-server-key Startup/renewal scripts Smallstep-issued RADIUS server identity
smallstep-ca-cert / smallstep-rsa-root-cert Startup script EC and RSA client roots; EC root also anchors the standard Smallstep RADIUS server
radius-accounting-key Terraform (inventory mode) Shared signing key for verified Class accounting bindings
scep-broker-token Terraform (inventory mode) Fleet-to-broker Basic authentication password
scep-challenge-signing-key Terraform (webhook enabled) Server-only SCEP challenge signing key
jamf-url Terraform (optional) Jamf Pro base URL for device lookup
jamf-client-id Terraform (optional) Jamf Pro API Client ID
jamf-client-secret Terraform (optional) Jamf Pro API Client Secret
unifi-url Terraform (optional) UniFi API URL for AP/site lookup
unifi-api-key Terraform (optional) UniFi API key

Server identities persist in Secret Manager across VM replacements. Install the root appropriate to the live server chain, and stage trust changes before CA rotation or a trust-mode migration.

Configuration

Variables

See terraform.tfvars.example for common settings and variables.tf for the complete schema. Key variables:

Variable Required Description
billing_account_id Yes GCP billing account to link
radius_clients Yes Office CIDRs and/or UniFi gateway host IDs; secrets auto-generated
okta_ca_cert_pem Yes Okta Intermediate PEM for okta/both; set "" for Smallstep-only deployments
okta_root_ca_cert_pem No Okta Root CA cert PEM (enables full chain validation)
datadog_api_key Yes Datadog API key for monitoring agent
ssh_allowed_cidrs No IPs for SSH access (default: GCP IAP)
secondary_zone No Zone for secondary VM (default: us-east4-c)
machine_type No VM size (default: e2-medium)
server_cert_cn Yes Server cert CN (e.g. radius.example.com)
server_cert_org Yes Organization name for CA cert subject (e.g. Acme Corp)
datadog_site No Datadog site (default: us5.datadoghq.com)
enable_fleet_lookup No Resolve serial → owner/device from Fleet (needs fleet_api_base_url + fleet-api-token secret; mutually exclusive with jamf_url)
fleet_api_base_url No Fleet server base URL (used by the Fleet lookup and the ACME webhook)
jamf_url No Jamf Pro URL — enables device lookup in auth logs (mutually exclusive with enable_fleet_lookup)
jamf_client_id No Jamf Pro API Client ID (requires Read Computers)
jamf_client_secret No Jamf Pro API Client Secret
rewrite_username No Display email - serial (legacy) or email - device_id (fingerprint mode) in Access-Accept; default false
rewrite_username_separator No Separator between email and serial in rewritten User-Name (default: -)
tls_session_cache No Enable faster re-auth (default: true); fingerprint enforcement always disables resumption
enable_fleet_certificate_inventory No Collect exact managed Apple and Windows machine certificate fingerprints; requires Fleet lookup and command/script permissions
fleet_acme_profile_uuids No Skip new certificate commands for verified ACME Wi-Fi profiles when radius_vlan_policy.attested_acme is enabled
fleet_scep_profile_uuids No Optional Wi-Fi SCEP profile allowlist for polling; null keeps all non-exempt hosts eligible, [] queues none
scep_broker_requests_per_minute No Challenge broker requests per source IP per minute (default: 1000); excess requests receive HTTP 429 without a timed ban
radius_vlan_policy No Global or per-location group mappings, site opt-out, and optional exact certificate enforcement
tls_session_cache_lifetime No TLS session cache lifetime in hours (default: 24)
tls_max_version No Max TLS version: 1.2 (default, disk cache works) or 1.3 (in-memory only)
unifi_url No UniFi API URL — enables AP/site name in auth logs
unifi_api_key No UniFi API key (read-only access)
datadog_app_key No Datadog Application key — enables Terraform-managed dashboard

Access Point RADIUS Setup

After deployment, run ./scripts/fetch-outputs.sh and open out/README.md for all IPs, shared secrets, and certs in one place.

Optional Meraki log enrichment uses organization device inventory and wireless SSID statuses to resolve exact hardware MACs and BSSIDs to AP and network names. The API key needs read access to both endpoints; no MAC offsets are guessed. This enrichment does not change authentication or VLAN selection.

Configure your access points (UniFi, Meraki, or any 802.1X-capable AP) with:

  • Primary RADIUS Server IP: from out/README.md or terraform output radius_primary_ip
  • Secondary RADIUS Server IP: from out/README.md or terraform output radius_secondary_ip
  • Auth Port: 1812
  • Accounting Port: 1813
  • Shared Secret: per-office, from out/shared-secret-<office>.txt
  • Interim Update Interval: 120 seconds (recommended)
  • Security: WPA2/WPA3 Enterprise
  • Enable RADIUS Assigned VLAN if using dynamic VLANs

Jamf Configuration

This is the legacy Okta SCEP path. The built-in fingerprint collector does not collect Jamf certificates; provide a trusted adapter before enforcing fingerprints.

  1. Upload the RADIUS server CA cert to Jamf:

    # After running ./scripts/fetch-outputs.sh
    cat out/radius-ca.cer

    Add this as a Certificate payload in a Jamf configuration profile.

  2. Create an SCEP payload:

    • SCEP Subject: CN=$SERIALNUMBER managementAttestation $UDID $PROFILE_IDENTIFIER
    • The bootstrap permits spaces in these legacy SCEP subjects
  3. Create a WiFi payload:

    • Security: WPA2/WPA3 Enterprise
    • EAP Type: EAP-TLS
    • Protocols tab Username: $SERIALNUMBER (used as EAP outer identity)
    • Identity Certificate: Okta SCEP certificate
    • Trust: Add the server CA cert above
    • Trusted Server Certificate Names: radius.example.com (must match server_cert_cn)
    • Disabling Private MAC Address is optional for stable MAC-based operations; certificate authorization does not require it

Okta Root CA (Optional)

For full chain validation (client cert → Intermediate CA → Root CA), you can provide the Okta Root CA certificate via the okta_root_ca_cert_pem variable. Without it, FreeRADIUS trusts only the Intermediate CA directly — this works, but won't survive an Intermediate CA re-key by Okta.

To obtain the Root CA from your Okta admin console (source):

  1. Log into your Okta Admin Dashboard
  2. In a new tab (same session), navigate to:
    https://<your-org>-admin.okta.com/api/v1/certificateAuthorities?type=ROOT
    
  3. Copy the id value from the JSON response
  4. In another tab, navigate to:
    https://<your-org>-admin.okta.com/api/v1/certificateAuthorities/<id>/cert
    
  5. A .cer file will download — paste its PEM contents into okta_root_ca_cert_pem in your terraform.tfvars

Fleet Device Lookup (Optional)

With radius_vlan_policy.certificate_inventory = true, authentication logs resolve the exact authenticated certificate fingerprint to a stable device_id and Fleet's device name, model, and assigned owner. serial contains only an actual inventory serial and remains empty when unavailable. raw_identity and cert_cn are diagnostic claims, never owner lookup keys. Owner/name fields remain empty if Fleet has no corresponding metadata or its cache is stale.

For hardware-attested Apple ACME, enable radius_vlan_policy.attested_acme to bind the verified certificate directly to Fleet's hardware serial inventory. This path checks the pinned Smallstep ACME signer, its CA-controlled provisioner marker, and matching permanent-identifier SAN/CN. It needs no certificate polling; BYOD/SCEP retains exact fingerprint authorization. See polling scope and rollout.

Accounting carries the verified device ID, certificate fingerprint, and original VLAN in a signed RADIUS Class value, bound to the configured office and client MAC. Both RADIUS nodes share a Secret Manager signing key, restored before FreeRADIUS on every boot. The NAS must supply Calling-Station-Id at authentication and echo Class in accounting. Missing, modified, wrong-office, wrong-client, or expired bindings are logged with identity_verified: false and empty device/owner fields. Bindings last 30 days; key rotation also invalidates existing bindings until devices reconnect. Confirm Class echo on a physical UniFi AP during rollout.

Datadog's recent-auth table includes device_id, fingerprint, VLAN, and owner. Expiry gauges count stable device IDs so serial-free devices are included. An expired certificate rejected before authentication cannot establish a verified owner: the expired-certificate monitor counts rejection events in inventory mode, including repeated attempts, rather than unique devices.

The following serial-based behavior applies to legacy mode:

The Fleet-managed counterpart of the Jamf lookup below — use this if Fleet is your MDM. When EAP-TLS authenticates a device, the outer identity is the serial number (e.g. H176YHQ9XV). With enable_fleet_lookup, a background cache script bulk-fetches all Fleet hosts (GET /api/v1/fleet/hosts?device_mapping=true) and stores them locally, keyed by serial. FreeRADIUS reads from this cache (no API calls on the auth path) to resolve the serial to device details. This adds the following fields to both auth and accounting JSON logs:

  • device_owner — assigned user's email from Fleet (device_mapping/end_users)
  • device_name — Fleet host display name
  • device_model — hardware model (e.g. Mac16,5)
  • If rewrite_username = true, overwrites User-Name in the reply to email - serial so UniFi and accounting show the owner

The cache is built on boot and refreshed every five minutes with VLAN policy enabled, or every 30 minutes otherwise. Cache misses trigger a background single-host fetch (GET /api/v1/fleet/hosts/identifier/{serial}, does not block auth). If Fleet is unreachable or the device isn't found, the serial is used as-is.

Fleet and Jamf are mutually exclusive — both populate the same enrichment fields. Set enable_fleet_lookup = true or jamf_url, not both (Terraform rejects both).

Setup:

  1. For lookup and ACME authorization alone, use a Fleet API-only Observer. Certificate collection instead needs a maintainer scoped to every managed fleet (or global maintainer), with MDM command and Windows script access. Confirm the account's effective role when creating it:

    fleetctl user create --name 'RADIUS Lookup' --api-only   # prints the token
  2. Store tokens in Secret Manager out-of-band (they never pass through tfvars/CI). Keep fleet-api-token observer-only for host lookup and ACME authorization. For certificate collection, create a separate secret containing the scoped maintainer token and set fleet_certificate_token_secret_id to its secret ID. Terraform grants the VM access; only the root-run collector reads its tmpfs credential. Existing deployments default to fleet-api-token for both roles.

    The following commands configure the observer token:

    First time (create the secret):

    printf '%s' '<token>' | gcloud secrets create fleet-api-token \
      --project=YOUR_PROJECT_ID --replication-policy=automatic --data-file=-

    If fleet-api-token already exists (e.g. created for the webhook), add a new version instead — create fails on an existing secret:

    printf '%s' '<token>' | gcloud secrets versions add fleet-api-token \
      --project=YOUR_PROJECT_ID --data-file=-
  3. Add to your terraform.tfvars:

    enable_fleet_lookup = true
    fleet_api_base_url  = "https://fleet.example.com"
  4. terraform apply — grants the RADIUS VM service account read access to fleet-api-token and updates the startup script.

Jamf Device Lookup (Optional)

This section describes legacy serial-based enrichment, not verified fingerprint attribution.

When EAP-TLS authenticates a device, the outer identity is the serial number (e.g. H176YHQ9XV). If you provide Jamf Pro API credentials, a background cache script bulk-fetches all Jamf inventory and stores it locally. FreeRADIUS reads from this cache (no API calls on the auth path) to resolve the serial to device details. This adds the following fields to both auth and accounting JSON logs:

  • device_owner — assigned user's email from Jamf
  • device_name — device name (e.g. Robbie's MacBook Pro)
  • device_model — hardware model (e.g. MacBook Pro (16-inch, 2024) M4 Max)
  • If rewrite_username = true, overwrites User-Name in the reply to email - serial so UniFi and accounting show the owner

The cache is built on boot and refreshed every five minutes with VLAN policy enabled, or every 30 minutes otherwise. Cache misses trigger a background fetch (does not block auth). If Jamf is unreachable or the device isn't found, the serial is used as-is.

Setup:

  1. In Jamf Pro, create an API Client (Settings → API Roles and Clients):

    • Create an API Role with the Read Computers privilege
    • Create an API Client, assign the role, and note the Client ID and Client Secret
  2. Add to your terraform.tfvars:

    jamf_url           = "https://yourorg.jamfcloud.com"
    jamf_client_id     = "your-client-id"
    jamf_client_secret = "your-client-secret"
  3. terraform apply — creates 3 new secrets in Secret Manager and updates the startup script

Dynamic VLAN assignment (Optional)

Map inventory groups to VLAN IDs with radius_vlan_policy. Fleet fleets/teams, Jamf computer sites, and custom MDM inventory snapshots share the same RADIUS policy. Serial-free BYOD iOS/iPadOS uses one Fleet-managed SCEP profile and authorizes exact certificate fingerprints from authenticated MDM inventory. Optional location mappings let the same fleet use different VLAN IDs in each office, selected by its authenticated RADIUS client configuration. UniFi receives Tunnel-Type = 13, Tunnel-Medium-Type = 6, and the VLAN ID in Tunnel-Private-Group-Id.

See configuration, UniFi setup, BYOD profiles, and issuance trust requirements.

UniFi AP/Site Lookup (Optional)

If you provide UniFi API credentials, FreeRADIUS will resolve the access point and site name for each authentication and accounting event. A cache script queries the UniFi API every 5 minutes and builds a local MAC-to-AP lookup table. The Python module matches the client's BSSID (from Called-Station-Id) to the AP's base MAC using fuzzy matching (last-byte offset 0-7). This adds to both auth and accounting JSON logs:

  • ap_name — access point name (e.g. Lobby)
  • site_name — UniFi site name (e.g. 32 Avenue of the Americas)
  • Auth logs also include ssid — extracted from Called-Station-Id

Setup:

  1. In UniFi, create an API Key (Settings → Admins & Users → API Keys) with read-only access

  2. Add to your terraform.tfvars:

    unifi_url     = "https://unifi.ui.com"
    unifi_api_key = "your-api-key"
  3. terraform apply — creates 2 new secrets in Secret Manager and enables the cache cron job

Datadog Dashboard (Optional)

Datadog Dashboard

If you provide a Datadog Application key, Terraform creates a dashboard with authentication metrics, device analytics, location breakdowns, accounting sessions, and infrastructure health.

  1. In Datadog, create an Application Key (Organization Settings → Application Keys) scoped to dashboards_read + dashboards_write only.

  2. Add to your terraform.tfvars:

    datadog_app_key = "your-application-key"
  3. terraform apply — creates the dashboard, outputs the URL via terraform output datadog_dashboard_url.

Without Terraform: Import datadog-dashboard.json (FreeRADIUS) and, if you run the Smallstep CA, datadog-smallstep-dashboard.json via Datadog UI → Dashboards → New Dashboard → Import Dashboard JSON.

The VLAN Assignments section adds a $vlan filter, accepted authentications by VLAN, distinct verified devices seen during the selected time range, a source-IP/VLAN breakdown, and a combined authentication/accounting event table. The VLAN filter applies only to this section, so the overview and reject widgets continue to show events without an assignment. Infrastructure metrics have no per-VLAN dimension.

In certificate inventory mode, UniFi/Meraki network names add vlan_name alongside vlan_id in authentication/accounting logs and both event tables (for example, 5 and Secure). Configure radius_vlan_name_sources for each office; UniFi WAN-discovery consoles are reused automatically. Names refresh every five minutes without API calls during authentication. Optional radius_vlan_policy.vlan_names labels provide a fallback. Names are display metadata and do not change authorization. Accounting labels the original signed VLAN using its current name at that site. Missing names stay blank; older logs are not backfilled.

VLAN IDs are local to each location. Use the site filter or source-IP breakdown to distinguish the same ID at different sites. Source IP remains available when UniFi site-name enrichment is absent. Distinct-device counts use @device_id and @identity_verified:true, including serial-free BYOD; they are not a count of currently connected sessions. A blank/missing vlan_id means no dynamic assignment was recorded (expected at opted-out sites), not VLAN 0 or a failure. It does not tell us the AP/switch's default VLAN.

Required: Create Log Facets

Create log facets for the dashboard's groupings, distinct-device counts, and $site / $vlan dropdown suggestions. Attribute searches work without facets. These are not auto-created — the Datadog Terraform provider does not support facet creation.

After your first log data arrives, go to Datadog → Logs → Facets → Add and create the following:

Facet Path Type Used by
@event @event String Auth widgets (Accept/Reject filtering)
@site_name @site_name String Site template variable, Auth by Site, Top APs
@ap_name @ap_name String Top Access Points
@ssid @ssid String Auth by SSID
@device_name @device_name String Top Devices
@device_owner @device_owner String Top Device Owners
@device_model @device_model String Device Model Distribution
@reject_reason @reject_reason String Reject Reasons, expired client certificate monitor
@serial @serial String Legacy expired client certificate monitor (device cardinality)
@device_id @device_id String Stable device identity, including serial-free BYOD
@certificate_fingerprint @certificate_fingerprint String Exact authenticated certificate
Assigned VLAN @vlan_id String VLAN filter, assignment trends, verified devices by VLAN
Assigned VLAN Name @vlan_name String Auth/accounting event tables, name searches and grouping
RADIUS Source IP @src_ip String Assignments by RADIUS Source / VLAN
@identity_verified @identity_verified Boolean Verified attribution versus diagnostic claims
@terminate_cause @terminate_cause String Session Termination Causes
@session_time @session_time Measure (seconds) Avg Session Duration
@input_bytes @input_bytes Measure (bytes) Bandwidth widgets
@output_bytes @output_bytes Measure (bytes) Bandwidth widgets

Tip: String facets can be added from any log entry — click the field value and select "Create facet". Measure facets (@session_time, @input_bytes, @output_bytes) must be created as Measures (not facets) to support aggregations like avg and sum.

Post-Deployment

SSH Access

# Via IAP tunnel (default, no public SSH needed)
$(terraform output -raw ssh_command_primary)     # Primary VM
$(terraform output -raw ssh_command_secondary)   # Secondary VM

# Check startup script progress
sudo cat /var/log/radius-bootstrap.log

# Check FreeRADIUS status
sudo systemctl status freeradius
sudo systemctl status mariadb
sudo systemctl status freeradius-exporter
sudo systemctl status datadog-agent

Testing RADIUS

From a host with an allowed source IP:

# Basic UDP/shared-secret check: an Access-Reject is expected (password auth is disabled)
radtest user password <radius-ip> 0 <shared-secret>

# Full EAP-TLS test (requires eapol_test from wpa_supplicant)
eapol_test -c eapol_test.conf -a <radius-ip> -s <shared-secret>

Check Accounting

sudo mysql radius -e "SELECT * FROM radacct ORDER BY radacctid DESC LIMIT 5"

Server Certificate Renewal

When radius_trust_mode is smallstep or both, RADIUS presents a Smallstep-issued server cert with a 90-day lifetime. radius-cert-renew.timer runs hourly on each node and re-mints the leaf once it has under 30 days left, then restarts FreeRADIUS.

# When does the live cert expire?
sudo openssl x509 -in /etc/freeradius/3.0/certs/server-cert.pem -noout -dates

# Timer state and last run
systemctl list-timers radius-cert-renew.timer
sudo journalctl -u radius-cert-renew.service --no-pager | tail -20

# Force a renewal now (no-op if more than 30 days remain)
sudo /usr/local/bin/radius-cert-renew.sh

An expired server cert is a total Wi-Fi outage with no server-side error: devices reject the cert themselves and FreeRADIUS only logs eap_tls: ERROR: (TLS) Alert read:fatal:certificate unknown. If you see that error en masse, check the server cert's expiry first. The renew script emits radius.server_cert.days_until_expiry (tag service:freeradius) via DogStatsD on every run, so alert on that gauge as the backstop — if the timer itself stops firing, the gauge goes stale rather than silently counting down.

Client Certificate Expiry

Client certificates normally have a 90-day lifetime. RADIUS cannot renew them; the device must enroll again through MDM. Fleet can schedule profile redelivery when the issued certificate contains its renewal ID in the subject OU. The Fleet ACME example and SCEP examples now include the appropriate renewal variable, and the CA templates preserve it. Existing certificates without that OU need a profile update to gain renewal tracking. Confirm renewal on real devices; adding the variable does not prove that already-issued certificates have it. See Fleet's renewal requirements.

For historical context, the September 2026 ACME outage involved a cohort of certificates without renewal tracking: no new orders arrived before expiry. Fingerprint authorization does not itself require those certificates to be reissued, but it also does not repair their renewal setup.

An expired client cert is a per-device lockout, and like the server-cert case nothing server-side goes red — radius_down and radius_no_accepts both stay green, because every device whose cert has not aged out yet still authenticates normally. The only signal is on the affected device's own auth attempt:

eap_tls: (TLS) OpenSSL says error 10 : certificate has expired

Three signals cover it, in order of lead time:

Signal Lead time Meaning
smallstep.x509.signed.count{provisioner:wifi-acme} flat for 24h varies no issuance observed; investigate renewal if orders were expected
radius.client_cert.expiring_soon{window:48h} > 0 2 days certs about to expire, nobody locked out yet
@reject_reason:"*certificate has expired*" in service:radius-auth none devices have lost Wi-Fi right now

radius-client-cert-metrics.timer emits the gauges hourly on each node, read from the local auth log (not the CA database — no credentials or schema coupling). It only sees devices that authenticated recently, which is the population that can be locked out.

# Gauge state and last run
systemctl list-timers radius-client-cert-metrics.timer
sudo journalctl -u radius-client-cert-metrics.service --no-pager | tail -20

# Emit now
sudo /usr/local/bin/radius-client-cert-metrics.sh

# Who is already locked out
sudo grep -F 'certificate has expired' /var/log/freeradius/radius-auth.json \
  | jq -c '{timestamp, device_id, identity_verified, serial, raw_identity, cert_cn, reject_reason}'

Restore alternate connectivity and correct the device's renewal/profile setup, then redeliver the profile to request a fresh certificate. In fingerprint mode, wait for a new trusted observation on both nodes before relying on Wi-Fi. Rejected certificate subjects are diagnostic claims, not verified device ownership.

To distinguish missing orders from CA failures, inspect issuance and webhook authorization counters for the ACME provisioner on the EC CA metrics endpoint:

curl -s http://127.0.0.1:9090/metrics | grep -E \
  '^step_ca_x509_(signed|webhook_authorized)_total\{provisioner="wifi-acme",success="(true|false)"\}'

Compare recent counter increases, not absolute totals: restarts, retries, and successful authorization followed by failed issuance can separate the two. Inspect both success and failure series and journalctl -u acme-authz-webhook for the actual denial reason. No new orders can indicate a device/MDM renewal problem; a flat issuance counter alone does not identify the cause.

After verifying end-to-end renewal, enable enable_acme_issuance_monitor for the 24-hour issuance alert. Keep certificate duration unchanged until that workflow is proven; a shorter duration also shortens the recovery window.

File Structure

.
├── main.tf                  # Provider, project, APIs, Secret Manager
├── variables.tf             # Input variables
├── network.tf               # VPC, subnet, firewall, static IP
├── compute.tf               # Service account, IAM, GCE instance
├── startup-transport.tf     # Private storage fallback for large startup scripts
├── outputs.tf               # IP, SSH command, RADIUS config
├── datadog.tf               # Optional Datadog dashboard (requires datadog_app_key)
├── datadog-smallstep.tf     # Smallstep CA dashboard + monitors + log pipeline
├── datadog-dashboard.json            # FreeRADIUS dashboard JSON export (importable via Datadog UI)
├── datadog-smallstep-dashboard.json  # Smallstep CA dashboard JSON export
├── terraform.tfvars.example # Example configuration with office IPs
├── ARCHITECTURE.md          # Technical deep-dive: auth flow, startup script, log enrichment
├── scripts/
│   ├── startup.sh           # FreeRADIUS + MariaDB install, EAP-TLS config, Datadog, exporter
│   └── fetch-outputs.sh     # Post-deploy: fetch certs & secrets to out/
└── out/                     # (gitignored) Certs, shared secrets, config.json, README.md

For a detailed technical walkthrough of the startup script, authentication flow, log enrichment pipeline, and caching architecture, see ARCHITECTURE.md.

Future Work

  • Multi-region: Deploy additional RADIUS nodes closer to west coast / new offices

About

FreeRADIUS on GCE via Terraform — HA 802.1X EAP-TLS for Ubiquiti UniFi WiFi with a self-hosted Smallstep CA (ACME + SCEP)

Topics

Resources

Stars

6 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages