Why and how we ‘stringified’ your Traffic Policy rules

When we released Traffic Policy at the beginning of the year, we quickly noticed a sizable “papercut” in the experience of configuring it with the agent or our SDKs. The original design required you to use highly typed and structured policy configuration (using Protobufs) to configure expressions and rules, which the agent and SDKs translated into a YAML document ngrok’s backend could understand to provision your endpoint.

This worked, but it was delicate to configure correctly. If you were an early adopter, you might have been burned once or twice by the fact that many meaningful changes to the agent, SDK, Traffic Policy engine, or specific actions required you to update your configurations.

In parallel to our work to ship new Traffic Policy phases, our agent and SDKs have all been updated with new fields that accept strings instead of those highly typed Traffic Policy rules. For example, this is what an IP restrictions Traffic Policy rule using the Go SDK looks like today:

func ngrokListener(ctx context.Context) (net.Listener, error) {
  return ngrok.Listen(ctx,
    config.HTTPEndpoint(
      config.WithTrafficPolicy(getStringPolicy()),
    )
    ngrok.WithAuthtokenFromEnv(),
  )
}

func getStringPolicy() string {
	return `
---
on_http_request:
  - name: "Restrict IPs to admin portal"
    expressions:
      - "req.url.contains('/admin')"
    actions:
      - type: "restrict-ips"
        config:
          enforce: true
          allow:
            - "10.0.0.0/8"
            - "220.12.0.0/16"
          deny:
            - "110.2.3.4/32"
`
}


Today, we’ll go over why we made these changes from typed structures to strings, what your Traffic Policy rules look like now, and how it impacts your current setup if you’re using the previous configuration experience.

What were the problems with typed structures and agent/SDK releases?

First, SDK/agent releases are labor intensive.

When adding or changing any tunnel configurations, we usually have to roll out the change in a crazy number of places. First, we have to update/release the Go SDK. Then, we have to use that change to update the Agent, releasing it to the Snap Store, Homebrew, Choclately, etc. 

In another path, we have to update/release the Rust SDK, then use that to update/release the Python, Java, and Javascript SDKs. 

As Traffic Policy evolves and grows within ngrok, this becomes a serious roadblock in rolling out updates and new features. Even if updating everything was a simple process, there is still the issue of supporting older configurations.

Second, some updates would break current user configurations.

Imagine a (strictly hypothetical!) scenario where we wanted to change the inbound phase to on_http_request. We go through the whole rollout process, updating the name in the agent and SDKs, and cut new releases.

But one of our users has used our Go SDK to create an endpoint and apply the same IP restrictions rule I started with at the top of this post:

func ngrokListener(ctx context.Context) (net.Listener, error) {
  return ngrok.Listen(ctx,
    config.HTTPEndpoint(
      config.WithPolicy(
        policy.Policy{
          Inbound: []policy.Rule{
            {
              Name: "Restrict IPs to admin portal",
              Expressions: []string{"req.url.contains('/admin')"},
              Actions: []policy.Action{
                {
                  Type: "restrict-ips",
                  Config: map[string]any{
                    "enforce": true,
                    "allow": {
                      "110.0.0.0/8",
                      "220.12.0.0/16",
                    },
                    "deny": {
                      "110.2.3.4/32"
                    },
                  }
                },
              },
            },
          },
        }
      )
    ),
    ngrok.WithAuthtokenFromEnv(),
  )
}


Unfortunately, Inbound: []policy.Rule, and all the following logic, is hardcoded directly into your app. The second we change the name and they update their SDK version to access a different new feature in ngrok, they also break their Traffic Policy rule.

What changed with stringification?

As I mentioned at the top, when starting an endpoint, the agent and SDKs would send a highly typed and structured policy configuration (using Protobufs) to the ngrok backend.

Along the way, we realized that prioritizing strings over structure, we could create a consistent experience while also better supporting changes in our backend—with far less maintenance and breakage for everyone.

However, we now send it over as a JSON or YAML string. These string policy configurations are then validated by the backend.

Agent example

The only change here is that policy has been renamed to traffic_policy.

endpoints:
  - name: example
    url: my-cool-domain.ngrok.app
    upstream:
      url: 8080
    traffic_policy:
      on_http_request:
        - name: "Restrict IPs to admin portal"
          expressions:
            - "req.url.contains('/admin')"
          actions:
            - type: "restrict-ips"
              config:
                enforce: true
                allow:
                  - "10.0.0.0/8"
                  - "220.12.0.0/16"
                deny:
                  - "110.2.3.4/32"

Go SDK examples

We replaced the old PolicyString function with WithTrafficPolicy(), which accepts a plain YAML/JSON string. Not only is that easier to create initially, but it frees you from updating this intricate interface every time we decide to add more actions or reconfigure an existing rule.

The example I led with applies here, but if you’re not a fan of tightly coupling your app or API with your Traffic Policy rules, you can just as easily save them to a separate YAML or JSON file and pull that in with your language’s interfaces for interacting with the OS.

func ngrokListener(ctx context.Context) (net.Listener, error) {
  return ngrok.Listen(ctx,
    config.HTTPEndpoint(
      config.WithTrafficPolicy(getPolicyFromFile()),
    )
    ngrok.WithAuthtokenFromEnv(),
  )
}

func getPolicyFromFile() string {
	b, _ := os.ReadFile("./some-yaml-policy-file.yaml")
	return string(b)
}

How does stringification impact current Traffic Policy users?

It doesn't (for now)! One of the nice things about server-side validation is that we can keep supporting old formats/configurations for as long as we need. By validating Traffic Policy server-side, we can add a translation layer on incoming tunnel start requests, allowing older configurations to continue to work after updates.

For the time being, you can update your agent/SDK versions without fear of breaking your current policy configurations! However, we do suggest updating your traffic policy configurations to the new format sooner rather than later. Another huge benefit of our new approach is that, once we add new features to our backend, those features are automatically available to all agents that support the new traffic_policy field (naming may vary by programming language)! 

For instance, if we add a new phase to Traffic Policy, any agent or SDK can immediately use it. This is especially helpful in situations where you want or need to hold off upgrading your ngrok agent or SDK version. It’s also nice that you get a consistent Traffic Policy configuration experience no matter which form factor you choose.

What Traffic Policy rule will you stringify next?

If Traffic Policy is completely new to you (or you’re even new to ngrok), start by creating an account for free.

Next, check out a few key resources on what you can do by writing Traffic Policy expressions and rules as strings:

And if you have questions about using stringifying your Traffic Policy rules, ask away on our community repo on GitHub.

Share this post
API gateway
Traffic Policy
Product updates
Company
Development
Production