Freestyle Docs

Freestyle / Guides

Route Sandboxes Through Your IP Proxy

Using VPCs and tunnels to route all VM traffic through an external server.

Routing VMs through your own server Several VMs sit on one private network. One tunnel carries the network to a machine you own, which reaches the internet. The internet sees that machine's address. VM VM VM Private network 10.100.0.0/22 one tunnel Your server 203.0.113.10 Internet

Install The SDK

pnpm add freestyle@latest
bun add freestyle@latest
npm install freestyle@latest
yarn add freestyle@latest
export FREESTYLE_API_KEY="your-api-key"

Create The Network And The Tunnel

import { Freestyle } from "freestyle";
import { writeFile } from "node:fs/promises";

const freestyle = new Freestyle();

const { vpcId } = await freestyle.vpc.create({
  slug: "exitnet",
  // 1,022 usable addresses. Size the network above the number of VMs you run
  // at once: an address is held for as long as the VM is attached.
  cidr: "10.100.0.0/22",
});

const tunnel = await freestyle.tunnels.create({
  slug: "exit-node",
  vpcs: [{ vpcId, exit: true }],
});

const [attachment] = tunnel.attachments;
console.log(attachment.ipv4, attachment.ipv6); // 10.100.0.1 fd20:…::1

await writeFile("exit-node.conf", tunnel.clientConfig, { mode: 0o600 });

Take both addresses. Pinning one family with ipv4 or ipv6 gives the attachment that family only, and the VMs then have no route for the other.

Build The Exit Node

Any Ubuntu machine with a public address. Give it a static one — the address is the point.

gcloud compute addresses create freestyle-exit-ip --region=us-west2

gcloud compute instances create freestyle-exit \
  --zone=us-west2-a \
  --machine-type=e2-micro \
  --image-family=ubuntu-2404-lts-amd64 --image-project=ubuntu-os-cloud \
  --address=freestyle-exit-ip \
  --can-ip-forward

gcloud compute scp exit-node.conf freestyle-exit:/tmp/exit-node.conf --zone=us-west2-a
gcloud compute ssh freestyle-exit --zone=us-west2-a
aws ec2 run-instances --region us-west-1 \
  --image-id ami-EXAMPLE --instance-type t3.micro \
  --security-group-ids sg-EXAMPLE --key-name my-key \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=freestyle-exit}]'

# A forwarding instance must not have its traffic dropped as spoofed.
aws ec2 modify-instance-attribute --region us-west-1 --instance-id i-EXAMPLE --no-source-dest-check

aws ec2 allocate-address --region us-west-1 --domain vpc
aws ec2 associate-address --region us-west-1 --instance-id i-EXAMPLE --allocation-id eipalloc-EXAMPLE

scp exit-node.conf ubuntu@203.0.113.10:/tmp/exit-node.conf
ssh ubuntu@203.0.113.10

Bring the tunnel up:

sudo apt-get update && sudo apt-get install -y wireguard-tools nftables

sudo install -d -m 700 /etc/wireguard
sudo install -m 600 /tmp/exit-node.conf /etc/wireguard/freestyle.conf

# The gateway has no way to dial your machine, so your machine holds the
# session open. Without this, a VM's first connection has nothing to reach.
echo "PersistentKeepalive = 25" | sudo tee -a /etc/wireguard/freestyle.conf

sudo systemctl enable --now wg-quick@freestyle
sudo wg show

Then forward and masquerade. Replace eth0 with the machine’s public interface:

sudo tee /etc/sysctl.d/99-exit-node.conf > /dev/null <<'EOF'
net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1
EOF
sudo sysctl -p /etc/sysctl.d/99-exit-node.conf

sudo tee /etc/nftables.conf > /dev/null <<'EOF'
flush ruleset

table inet exit {
  chain postrouting {
    type nat hook postrouting priority srcnat;
    oifname "eth0" masquerade
  }

  chain forward {
    type filter hook forward priority filter;
    # A tunnelled path is smaller than the local link. Clamping keeps large
    # responses from being dropped without an ICMP that survives the path.
    tcp flags syn tcp option maxseg size set rt mtu
  }
}
EOF

sudo systemctl enable --now nftables

Add A VM

One standing rule lets every member of the network — now and later — reach the exit, and nothing else.

await freestyle.firewall.rules.create({
  action: "allow",
  source: { vpcId },
  destination: { tunnelId: tunnel.tunnelId },
});

Then each VM states where its default route goes. Both families, or the family you leave out keeps the default route Freestyle advertised and has nowhere to go.

const viaExit = [
  { cidr: "0.0.0.0/0", via: attachment.ipv4 },
  { cidr: "::/0", via: attachment.ipv6 },
];

const { vm } = await freestyle.vms.create({
  slug: "worker-1",
  vpcs: [{ vpcId, routes: viaExit }],
  firewall: { rules: [] },
});

The second VM is the same two fields, and the hundredth is too. The exit node is not touched, and no key is generated per VM.

Identify VMs At The Exit

Your machine sees each VM’s address inside the network as the source — the address is not rewritten on the way out. Nothing carries the VM’s id with it, so record the mapping when you create the VM:

const { vmId, data } = await freestyle.vms.create({
  vpcs: [{ vpcId, routes: viaExit }],
  firewall: { rules: [] },
});

const [network] = data.vpcs;
await store.set(network.ipv4, vmId);
await store.set(network.ipv6, vmId);

Addresses are released on delete and handed to a later VM, so clear the entry when you delete the VM. To keep an address for the life of a workload instead, pin it at create with ipv4.

Verify

const check = await vm.exec({
  linuxUser: "root",
  timeoutMs: 60_000,
  command: "wget -qO- --timeout=20 http://1.1.1.1/cdn-cgi/trace | grep '^ip='",
});

console.log(check.stdout); // ip=203.0.113.10 — your exit node's address

On the exit node, sudo wg show freestyle transfer counts the bytes carried.

Two Exit Nodes

One exit node is one machine to lose. A network may have several exits: attach a second tunnel with exit: true, and give the VMs a route through each.

const second = await freestyle.tunnels.create({
  slug: "exit-node-2",
  vpcs: [{ vpcId, exit: true }],
});

await freestyle.firewall.rules.create({
  action: "allow",
  source: { vpcId },
  destination: { tunnelId: second.tunnelId },
});
Two exit nodes Several VMs sit on one private network. The network has two tunnels, each to a machine you own, and every VM holds a route through both. Either machine reaches the internet, and traffic leaves from that machine's address. VM VM VM Private network 10.100.0.0/22 two tunnels Your server 203.0.113.10 Your server 198.51.100.7 Internet

Build the second machine exactly like the first, in another region or another provider.

Routes at the same metric are one multipath route. The guest spreads connections across both exits, and drops one that stops answering:

const exits = [attachment, second.attachments[0]];

const viaBoth = exits.flatMap((exit) => [
  { cidr: "0.0.0.0/0", via: exit.ipv4, metric: 100 },
  { cidr: "::/0", via: exit.ipv6, metric: 100 },
]);

const { vm: worker } = await freestyle.vms.create({
  slug: "worker-2",
  vpcs: [{ vpcId, routes: viaBoth }],
  firewall: { rules: [] },
});

await worker.exec({
  linuxUser: "root",
  command: `set -eux
tee /etc/sysctl.d/99-multipath.conf <<'EOF'
# Do not select a next hop whose neighbour has stopped answering.
net.ipv4.fib_multipath_use_neigh=1
# Spread by connection rather than by address pair, so traffic to one
# destination still uses both exits.
net.ipv4.fib_multipath_hash_policy=1
EOF
sysctl -p /etc/sysctl.d/99-multipath.conf`,
});

Give one exit a higher metric instead, and it becomes a cold standby: the lower metric carries everything while it works.

Freestyle stops answering address resolution for an exit whose tunnel has no live session, which is what the guest’s fib_multipath_use_neigh watches. An exit node that is up but has lost its own internet keeps answering and keeps taking traffic — health-check the machines themselves if that matters.

Notes

  • A VM’s own rules still bound what it can reach. firewall: { rules: [] } is the whole internet by way of the exit, and nothing else. Narrow it — destination: { cidr: "203.0.113.0/24" } — and only that survives the trip.
  • Outbound mail stays closed. Ports 25, 465, and 587 are denied at the platform whatever your rules say, and routing them through your own machine does not change where the traffic leaves Freestyle.
  • Deleting a tunnel deletes the rules naming it, which leaves the VMs pointing at an exit they may no longer reach.
  • Reserve the exit node’s address. An ephemeral cloud address changes under you, and it is the address you gave a vendor for their allowlist.
  • To inspect and modify HTTP and HTTPS on the exit node, see inspecting sandbox traffic with mitmproxy.
  • To reach hosts behind your machine rather than the internet in front of it, see routing networks behind your client.
esc