Freestyle Docs

Freestyle / Docs

TLS

Publish domains to VMs, inject secrets into outbound calls, and connect VMs to each other by name.

A TLS rule governs a named session: domain, from source, to destination. Where a firewall rule is about packets between addresses — layer 3/4 — a TLS rule is about a session addressed to a name — layer 7. The two are deliberately separate APIs with one shared grammar, so a reader who knows the firewall reads this by reflex: source says who may open the session, destination says where it lands, and both ends carry the firewall’s identity/address vocabulary with a domain on top.

A TLS rule only ever grants — it never blocks. This is the load-bearing difference from the firewall, and it runs the other way. The firewall is the deny-by-default packet layer; a TLS rule is a grant layered on top of it that adds a named, brokered path through the platform edge and takes nothing away. There is no such thing as a TLS rule that denies a domain. “Allow only these domains” is not a thing you say here — it is a thing you get, by saying nothing at the IP layer (no firewall egress rule, so no raw-IP Internet) and then naming the domains you want through TLS rules.

Publish A Domain To A VM

The open Internet dials a name; the session lands on a VM’s port. The edge terminates HTTPS at the domain and forwards to the backend.

import { Freestyle } from "freestyle";

const freestyle = new Freestyle();

// Anyone dialing app.acme.com reaches vm-123456 on port 8000.
await freestyle.tls.rules.create({
  action: "allow",
  domain: "app.acme.com",
  source: { public: true },
  destination: { vmId: "vm-123456", port: 8000 },
});

domain is a hostname you control — an exact name like app.acme.com, or a single-label wildcard like *.acme.com. The edge presents a certificate for it, so it must be a name the platform can serve: one you have verified, or any unused subdomain of style.dev, which is free and needs no verification or DNS records. You write no firewall rule to make this work: the edge reaches the VM over a standing grant every VM already has, and a TLS ingress rule mints no firewall rule of its own.

You do not set protocol — the edge serves HTTP, and that is the default.

Publish A Minecraft Server

Set protocol: "minecraft" and the rule is served by the edge’s Minecraft front on 25565 instead of over HTTPS.

// Players dialing mc.acme.com reach vm-123456's Minecraft server.
await freestyle.tls.rules.create({
  action: "allow",
  domain: "mc.acme.com",
  protocol: "minecraft",
  source: { public: true },
  destination: { vmId: "vm-123456", port: 25565 },
});

Point the domain’s A and AAAA records at the edge, exactly as for a web domain (see DNS). Players type mc.acme.com with no port; no SRV record is needed while the server is on 25565.

The edge reads the server address out of the client’s handshake — the routing key, the way Host is over HTTP — and splices the session through. It terminates nothing: a minecraft rule takes no transform, and the name needs no certificate.

A status ping — the row in a player’s multiplayer list — never starts a stopped VM. While the VM is stopped the row reads Sleeping — join to start the server; while it is running the player sees the server’s own MOTD, player count and icon. Joining starts the VM.

Two settings on the guest, because the edge sits between the player and the server:

  • prevent-proxy-connections=false in server.properties, or an online-mode server rejects every login.
  • Minecraft: Java Edition only. Bedrock Edition is a different protocol over UDP.

Publish A Port With Your Own Certificate

Set protocol: "tcp" and the edge matches the name in the client’s ClientHello and splices the connection to the VM without terminating it. Your VM answers the handshake with its own certificate.

// Clients dialing secure.acme.com reach vm-123456 on 8443, TLS end to end.
await freestyle.tls.rules.create({
  action: "allow",
  domain: "secure.acme.com",
  protocol: "tcp",
  source: { public: true },
  destination: { vmId: "vm-123456", port: 8443 },
});

Point the domain’s A and AAAA records at the edge, exactly as for a web domain (see DNS). Clients dial 443, the same port an http rule is served on; the SNI is what selects the rule. Serve the certificate from the VM — the platform issues none for the name, and a client that offers no SNI never reaches the rule.

Passthrough is what you reach for when the session is yours to end: mutual TLS your server verifies, a protocol over TLS that is not HTTP, or a certificate you issue yourself. What it gives up is everything the edge would otherwise do:

  • No transform. The edge holds no key to the session.
  • No HTTP/3. There is no TCP connection to splice.
  • Public ingress only — { public: true } -> { vmId, port }.
  • One name, one rule. A name published for passthrough is not also served as HTTPS; on 443 the more specific rule wins, and an exact tcp rule beats a *.acme.com http one.

Publish A Mailbox

Set protocol: "imap" and the edge serves the name on 993, terminates the TLS, and forwards plain IMAP to your VM.

// Mail clients dialing mail.acme.com on 993 reach dovecot on vm-123456:143.
await freestyle.tls.rules.create({
  action: "allow",
  domain: "mail.acme.com",
  protocol: "imap",
  source: { public: true },
  destination: { vmId: "vm-123456", port: 143 },
});

Point the domain’s A and AAAA records at the edge (see DNS). The name is issued a certificate through the same ACME pipeline an http rule uses, so your VM runs an unencrypted IMAP server on 143 and holds no key material.

Users configure their mail client with the domain as the IMAP server, port 993, SSL/TLS. Accounts and passwords are your IMAP server’s — the edge reads no part of the session past the handshake and injects nothing, so an imap rule takes no transform.

To terminate the TLS yourself, set protocol: "imaps" instead and land the rule on 993:

await freestyle.tls.rules.create({
  action: "allow",
  domain: "mail.acme.com",
  protocol: "imaps",
  source: { public: true },
  destination: { vmId: "vm-123456", port: 993 },
});

The edge then matches the SNI and splices, and your server answers the handshake with a certificate you obtain and renew. The platform issues none for the name.

Both are public ingress only, and 993 takes one rule per name: an exact imaps rule beats a *.acme.com imap one, the way 443 resolves tcp against http.

Publish A Database

Set protocol: "postgres" and the rule is served by the edge’s Postgres front on 5432.

// Clients connecting to db.acme.com reach vm-123456's Postgres on 5432.
await freestyle.tls.rules.create({
  action: "allow",
  domain: "db.acme.com",
  protocol: "postgres",
  source: { public: true },
  destination: { vmId: "vm-123456", port: 5432 },
});

Point the domain’s A and AAAA records at the edge, exactly as for a web domain (see DNS). Connection strings use sslmode=require or stricter:

psql "postgresql://app@db.acme.com/prod?sslmode=verify-full"

The edge terminates TLS at the domain and forwards the session to the VM’s Postgres port. Your own server runs the startup exchange and authenticates the client; the edge reads nothing past the handshake, so a postgres ingress rule takes no transform. The name needs a certificate, so it must be one the platform can serve — the same rule as an HTTPS domain.

An unencrypted connection (sslmode=disable) is refused: it carries no server name, and the name is what selects the rule.

Reach A Database With Credentials Injected

A VM connects to a database out on the world; the edge authenticates upstream with credentials the guest never holds and hands back the open session.

// vm-123456 may reach db.vendor.com, connecting as `app` with a password it
// was never given.
await freestyle.tls.rules.create({
  action: "allow",
  domain: "db.vendor.com",
  source: { vmId: "vm-123456" },
  destination: { public: true },
  transform: [{ postgres: { username: "app", password: "…", database: "prod" } }],
});

protocol is inferred from the transform. In the guest, connect with any user and no password:

psql "postgresql://db.vendor.com/?sslmode=require"

The edge replaces user — and database when the transform names one — and completes the origin’s authentication, whether it asks for SCRAM-SHA-256, MD5, or a cleartext password. password is write-only: sealed at rest and read back as "***". The origin’s certificate is verified against the public root CAs on every connection.

A postgres transform belongs on an egress rule: a destination of public: true or a host. On a rule landing on your own VM it is refused.

A public origin with no port defaults to 5432.

Reach A Domain With A Secret Injected

For Git clone, fetch, and push with credentials held at the edge, see Use Private Git Repositories in a Sandbox.

A VM opens a session to a domain out on the world; the edge terminates it, injects a header the guest never held, and re-originates to the real origin. The destination is { public: true }the domain’s own public origin, wherever the world’s DNS says the name lives — never a blank matcher.

// vm-123456 may reach api.openai.com, and the edge adds an Authorization
// header the guest itself was never given.
await freestyle.tls.rules.create({
  action: "allow",
  domain: "api.openai.com",
  source: { vmId: "vm-123456" },
  destination: { public: true },
  transform: [{ headers: { authorization: "Bearer sk-…" } }],
});

This is the point of a transform: it does something the guest could not do itself. A compromised guest cannot exfiltrate a key it was never handed, because the key lives only at the edge. Header values are write-only — sealed at rest and read back as "***" (header names survive a read, so you can still audit which headers a rule sets).

Two pieces make this work: the firewall admits the guest’s packets to the edge (so a VM with no raw-IP Internet still reaches its granted domains), and the guest agent installs exact-domain entries in the VM’s /etc/hosts pointing to the edge. A whole network may open the session too — use source: { vpcId: "vpc-backend" }.

The installed entries can include both IPv4 and IPv6 addresses. The client must use an address reachable from its own network. Containers and clients that bypass the VM’s hosts file do not automatically follow those entries. See Docker TLS routing for container hostname mappings and an IPv6-enabled network. A client that dials the origin’s IP directly bypasses this steering and receives no injected headers; any direct connection still needs a firewall grant.

Terminating a real domain means presenting a certificate the guest trusts for that name — the platform CA installed in the VM when egress is configured. A guest that pins certificates will refuse; for those, the honest path is an alias the guest knowingly dials, pinned with a host on the destination. Host-alias rules currently need an existing allowed path to the edge; see the restricted-egress limitation. The direct-provider public: true rule above supplies an automatic edge grant.

// The guest dials vendor-alias.internal; the edge originates to the real host.
await freestyle.tls.rules.create({
  action: "allow",
  domain: "vendor-alias.internal",
  source: { vmId: "vm-123456" },
  destination: { host: "api.vendor.com", port: 443 },
  transform: [{ headers: { authorization: "Bearer sk-…" } }],
});

Configure Client Certificate Trust

On Ubuntu, Freestyle installs its public CA at /usr/local/share/ca-certificates/freestyle-tls.crt and refreshes /etc/ssl/certs/ca-certificates.crt. This changes the VM’s system store; it does not modify every runtime or a Docker container’s filesystem. The same distinction applies after you install your own proxy or private CA.

Point each client at the bundle containing the required CA. For processes running directly in an Ubuntu VM:

ClientEnvironment setting
Python RequestsREQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
Python HTTPXSSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
Node.jsNODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt

Pass these variables through vm.exec({ command, env: { ... } }), your service configuration, or the application’s launch command. Restart existing clients after changing trust; Node reads its additional-CA setting at startup.

Requests normally uses certifi. Installing a CA in the system store, setting only SSL_CERT_FILE, or mounting a certificate file without configuring Requests is insufficient. For an explicit request, pass the bundle with verify:

response = requests.get(url, verify="/etc/ssl/certs/ca-certificates.crt", timeout=30)

For Session.send() with a prepared request, pass verify explicitly or merge the session’s environment settings. That flow does not automatically apply REQUESTS_CA_BUNDLE. Likewise, a custom HTTPX client with trust_env=False needs an explicit SSL configuration instead of relying on environment variables. See Node’s CA setting for clients that supply their own TLS ca option.

Inside Docker, the bundle must exist inside the container, and the variables must be set on its process. Use the container CA recipes. Registry pulls have a separate daemon trust configuration. Keep verification enabled; a working curl request in the VM does not prove that Requests or a container trusts the same CA.

Match HTTP Egress Requests

An optional match beside transform selects which HTTP egress requests receive all of that rule’s transforms:

match: {
  method: ["POST", "PUT"],
  path: { exact: "/v1/responses" },
},
transform: [
  { headers: { authorization: `Bearer ${providerKey}` } },
  { jsonPatch: [{ op: "add", path: "/model", value: "approved-model" }] },
],
Field or behaviorMeaning
matchConditions apply to the entire transform list. Omitted fields impose no condition; {} matches every request.
method: string[]Any listed method matches; an empty array matches none. Method and path conditions must both match.
Method spellingCase-sensitive HTTP tokens per RFC 9110 §9.1; extension methods are accepted.
path: { exact: string }Case-sensitive equality. /v1/responses/ differs from /v1/responses.
Query strings/v1/responses?stream=true matches the path /v1/responses per RFC 9112 §3.2.1. Put no query or fragment in exact.
Non-matching requestsForwarded without this rule’s transforms, including credential injection. Matching does not deny origin access.

The edge compares the original method and raw URI path before reading the body. It does not percent-decode paths, collapse dot segments, or normalize slashes. Use the spelling your client sends. Thus GET /v1/models can pass through with its original body and headers while POST /v1/responses receives both the model patch and injected credentials. Unmatched requests retain client-supplied headers.

Freestyle currently supports method and exact path only. Unsupported fields are rejected. match is accepted on HTTP egress rules to a public origin or host; it does not change domain/source rule selection, choose another destination, or fall through to another TLS rule. Match conditions are visible on reads, so do not put secrets in them. Updates replace the full rule: include match to retain it, or omit it to restore unconditional transforms.

Transform JSON Request Bodies

For complete provider recipes, see OpenAI models and authentication, Anthropic models and authentication, OpenRouter models and authentication, and migrating from OpenShell.

A jsonPatch transform applies standard RFC 6902 JSON Patch operations to HTTP egress request bodies. Authentication stays in a separate headers transform, and destination selects the upstream:

await freestyle.tls.rules.create({
  action: "allow",
  domain: "inference.local",
  source: { vmId: "vm-123456" },
  destination: { host: "api.openai.com", port: 443 },
  match: { method: ["POST"], path: { exact: "/v1/responses" } },
  transform: [
    { headers: { authorization: `Bearer ${providerKey}` } },
    { jsonPatch: [{ op: "add", path: "/model", value: "approved-model" }] },
  ],
});

Run this in your trusted controller. The guest calls https://inference.local/v1/responses with its usual JSON request. A client SDK that requires an API key can use a placeholder; the edge injects the real key. For OpenAI Chat Completions, change the matched path to /v1/chat/completions. For Anthropic Messages, match /v1/messages, select api.anthropic.com, and inject x-api-key; the client still supplies required headers such as anthropic-version. The /model patch itself is the same.

add on an object is an upsert: it creates /model when absent and replaces it when present. It does not mean “set only if missing.” The six operations use standard semantics:

OperationBehavior
addInsert or overwrite an object member; insert an array element, shifting later elements. /- appends to an existing array.
replaceReplace an existing value, including an array element without shifting.
removeRemove an existing value.
copyCopy the value at from to path, with add destination semantics.
moveRemove the value at from, then add it at path.
testRequire an existing value to equal value; a failed test rejects the request.

Paths use RFC 6901 JSON Pointer. For example, /messages/0/content selects the first message’s content, ~1 escapes /, and ~0 escapes ~. The empty path selects the whole document. Parent objects and arrays must exist. There are no wildcards, automatic parent creation, conditional defaults, or foreach operations. null is an ordinary value; use remove to delete a field. Removing the document root or moving from the root is unsupported; use add or replace with an empty path to replace it.

{ jsonPatch: [
  { op: "add", path: "/model", value: "approved-model" },
  { op: "replace", path: "/messages/0/content", value: "Updated first message" },
  { op: "add", path: "/tools/-", value: extraTool },
] }

Operations run in order. Every operation must succeed before the request is forwarded. The example requires an existing first message and tools array. Use one jsonPatch per rule, alongside any header transforms, on HTTP egress from a VM or VPC to a public origin or host. Patches accept at most 128 operations and 64 KiB of serialized configuration. Operation value fields are write-only: sealed at rest and returned as "***". Operation names, path, and from remain visible; do not put secrets in pointer paths.

Without match, the patch applies to every request on the selected TLS rule. With match, only matching requests receive the patch and injected headers. JSON Patch does not provide a provider adapter or complete model-access policy. A /model patch does not constrain Anthropic fallback models, nested batch entries, or models selected by an uploaded JSONL file. Fixed nested fields can be addressed explicitly; arbitrary-length collections need a gateway with richer policy. It does not translate between OpenAI and Anthropic formats or validate provider-specific schemas.

Requests selected for JSON Patch must be UTF-8 application/json (an optional UTF-8 charset is accepted). Non-JSON and compressed bodies get 415; WebSocket upgrades and CONNECT get 403. Empty bodies, malformed JSON, request trailers, failed operations, or complexity/output-limit violations get 400. The edge bounds input and each intermediate/output document to 32 MiB, with at most 262,144 JSON values and 64 levels of nesting. Copies also reserve space for their source before allocation. Oversized incoming bodies get 413. The body must arrive within 30 seconds (408); a busy edge can return 503. The upstream must return response headers within five minutes (504). Responses, including SSE, stream through without JSON rewriting or body buffering. JSON whitespace and object ordering may change; numeric precision is preserved. Duplicate object names use their last value and are written upstream once. Scope the transform with match to let unrelated multipart uploads and body-less retrieval pass through without patching or credential injection. Matching multipart, JSONL, and WebSocket requests remain unsupported. Ordinary JSON HTTP inference, including requests asking for streamed responses, works with this transform.

Use freestyle.tls.rules.update(ruleId, fullRule) to change the patch for later requests. Supply the entire rule, including actual patch values and header secrets. A redacted read cannot reconstruct them. Cached rules can take about five seconds to refresh; updates do not alter requests already in flight. Editing JSON Patch or matched rules in the dashboard currently requires the SDK or CLI, preserving write-only values and request conditions.

The CLI accepts the same operation array through --json-patch on tls create and tls update, composable with --header and the JSON --match option:

freestyle tls create --domain inference.local --from vm=vm-123456 \
  --to host=api.openai.com,port=443 \
  --header 'authorization=Bearer sk-…' \
  --match '{"method":["POST"],"path":{"exact":"/v1/responses"}}' \
  --json-patch '[{"op":"add","path":"/model","value":"approved-model"}]'

Transforms keep the normal firewall semantics: omit broad public-egress grants when the guest must use only its named TLS routes. A transform affects its selected route, not other routes the VM can access.

Send A VM Out Through Your Own Proxy

A VM’s outbound sessions leave through a SOCKS5 proxy you supply. The edge authenticates to the provider; the VM never holds the credentials.

// Everything vm-123456 opens leaves through gate.provider.com.
await freestyle.tls.rules.create({
  action: "allow",
  domain: "*",
  source: { vmId: "vm-123456" },
  destination: { host: "gate.provider.com", port: 7000 },
  transform: [{ socks5: { username: "cust-9", password: "…" } }],
});

protocol is inferred from the transform. The destination is the proxy’s endpoint, and both host and port are required — { public: true } is refused.

A catch-all rule sets all_proxy and ALL_PROXY in the guest, in /etc/environment and in /etc/profile.d/freestyle-socks5-proxy.sh. exec, PTY and SSH sessions all inherit them:

curl https://api.ipify.org   # answers with the provider's address

The endpoint is socks5h://socks5.freestyle.internal:1080. Point a client at it directly to use it without the environment. Use socks5h, not socks5: the proxy must receive the hostname, and the hostname selects the rule.

domain on a socks5 rule is an allow-list of destinations, checked against what the guest asked for in the SOCKS request. * allows any destination and is accepted here only. A narrower rule allows what it names and nothing else:

// vm-123456 reaches anything under target.com through the proxy.
await freestyle.tls.rules.create({
  action: "allow",
  domain: "*.target.com",
  source: { vmId: "vm-123456" },
  destination: { host: "gate.provider.com", port: 7000 },
  transform: [{ socks5: { username: "cust-9", password: "…" } }],
});

Only a catch-all rule writes the guest’s proxy environment. A narrower rule leaves it alone; aim a client at the endpoint yourself.

The transform is optional. A provider that allow-lists by source address needs no username or password.

The edge terminates nothing here: it opens the connection through the proxy and copies bytes. Your TLS runs end to end to the origin, no platform CA is installed in the VM, and no certificate is issued.

socks5 is egress only. Its password is write-only: sealed at rest, read back as "***".

Connect One VM To Another By Name

A rule whose destination names another VM is an internal service — the same edge-brokered path as a public domain, pointed inward. The source opens https://the-name; the platform edge terminates it, presenting a certificate the guest trusts, and forwards to the target VM’s port. You address the service as plain HTTPS and the edge maps it to whatever port the backend listens on.

// vm-web reaches vm-db's admin UI as "db.internal": https://db.internal
// terminates at the edge and forwards to vm-db:8000.
await freestyle.tls.rules.create({
  action: "allow",
  domain: "db.internal",
  source: { vmId: "vm-web" },
  destination: { vmId: "vm-db", port: 8000 },
});

Why the edge, and not a direct VM-to-VM hop? The certificate has to be resolved outside the guest. A VM could only present a platform-trusted certificate for the name by holding the platform’s private key — which no guest ever does — so the edge is the one place the name can terminate. Two grants make it reach: the firewall admits the source’s packets to the edge, and the standing platform grant already lets the edge reach the target VM. Both are grants, not gates — a VM that already had a path is unaffected.

An internal service can also be Postgres. Set protocol: "postgres" and the source connects to the name on 5432 instead of opening https://the-name:

// vm-web reaches vm-db as "db.internal" on 5432.
await freestyle.tls.rules.create({
  action: "allow",
  domain: "db.internal",
  protocol: "postgres",
  source: { vmId: "vm-web" },
  destination: { vmId: "vm-db", port: 5432 },
});

An internal service carries no transform, whatever its protocol.

Only exact names steer this way — the guest’s /etc/hosts has no wildcards. A *.suffix or catch-all internal name needs the platform resolver.

Source And Destination

The two ends use the firewall’s vocabulary, but they are not symmetric — the asymmetries fall out of what each side means.

FieldOn a sourceOn a destination
vmIdThe VM that may open the sessionThe VM the session lands on (needs port)
vpcIdEvery member of a network may open itNot allowed — a landing is one place
publicEvery publicly routable clientThe domain’s own public origin
hostNot allowed — a source is who, not wherePin the landing to a host (needs port)
portNot allowed — a source carries no landingThe port the session lands on

A source is a pure identity — who opens the session, authenticated by its switch port and anti-spoofed address, never by the name it dials (the name a client offers proves nothing about the client). A destination is a landing — exactly one of vmId, host, or public: true, never a whole network, because “which member does this land on” has no answer.

public: true never comes from a forgotten field. A blank matcher does not mean “the Internet” here for the same reason it never does in the firewall: the most dangerous statement you can write must not be the one you get by omission. Every rule states both ends.

Declaring Rules With A VM

vms.create takes an inline tls block, where an endpoint with no identity means the VM being created — ingress to it on a bare destination, egress from it on a bare source. Exactly one end may be left bare:

const { vm } = await freestyle.vms.create({
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
  tls: {
    rules: [
      // Inbound HTTPS to this VM on 8000.
      { action: "allow", domain: "app.acme.com", source: { public: true }, destination: { port: 8000 } },
      // This VM out to a vendor, with a key injected.
      {
        action: "allow",
        domain: "api.openai.com",
        source: {},
        destination: { public: true },
        transform: [{ headers: { authorization: "Bearer sk-…" } }],
      },
    ],
  },
});

Leaving both ends bare would describe the VM talking to itself; leaving neither bare would describe a rule with nothing to do with the new VM — declare that one with tls.rules.create instead. If any inline rule is invalid, the whole create fails and no VM is made.

Rotating A Secret

Rules are replaceable in place — the one way this API diverges from the firewall’s create/delete immutability. A transform carries a rotating secret, and delete-then-create would drop traffic or trip your account’s rule limit mid-swap. update keeps the rule’s id and creation time and replaces everything else:

// Rotate the injected key without taking the path down.
await freestyle.tls.rules.update("tls-123456", {
  action: "allow",
  domain: "api.openai.com",
  source: { vmId: "vm-123456" },
  destination: { public: true },
  transform: [{ headers: { authorization: "Bearer sk-rotated" } }],
});

Because secrets never read back, an update always states the full transform — there is no “keep the old value”. A read carries redacted: true when it has hidden a secret, so you know the shape you see is not the whole story.

List, Get, And Delete

// Every rule in your account, newest first.
const { rules } = await freestyle.tls.rules.list();

// The rules that apply to one VM: those naming it, plus those naming a
// private network it is on.
const { rules: applied } = await freestyle.tls.rules.list({ vmId: "vm-123456" });

// Or the rules naming one network.
await freestyle.tls.rules.list({ vpcId: "vpc-backend" });

const rule = await freestyle.tls.rules.get("tls-123456");

await freestyle.tls.rules.delete("tls-123456");

From The CLI

freestyle tls create --domain app.acme.com --from public --to vm=vm-123456,port=8000
freestyle tls create --domain db.acme.com --protocol postgres \
  --from public --to vm=vm-123456,port=5432
freestyle tls create --domain db.vendor.com --from vm=vm-123456 --to public \
  --pg-user app --pg-password 'hunter2' --pg-database prod
freestyle tls create --domain mc.acme.com --protocol minecraft \
  --from public --to vm=vm-123456,port=25565
freestyle tls create --domain mail.acme.com --protocol imap \
  --from public --to vm=vm-123456,port=143

freestyle tls create --domain secure.acme.com --protocol tcp \
  --from public --to vm=vm-123456,port=8443
freestyle tls create --domain api.openai.com --from vm=vm-123456 --to public \
  --header 'authorization=Bearer sk-…'
freestyle tls list --vm vm-123456
freestyle tls update tls-123456 --domain api.openai.com --from vm=vm-123456 --to public \
  --header 'authorization=Bearer sk-rotated'
freestyle tls delete tls-123456

Each endpoint is comma-separated key=value pairs (vm, vpc, host, port), or the bare word public. Repeat --header name=value for each injected header; as everywhere, the values are write-only. --protocol takes http (the default), tcp, postgres, minecraft, imap, imaps, or socks5. --pg-user, --pg-password, and --pg-database build a Postgres transform, and --socks5-user/--socks5-password a proxy one; they are mutually exclusive with --header, since one rule terminates one protocol.

Lifecycle

A rule cannot outlive what it names. Delete a VM or a private network and the rules referencing it go too — the same cascade the firewall uses — so you never end up with a rule pointing at a machine that no longer exists. Every rule reports what it depends on:

const rule = await freestyle.tls.rules.get("tls-123456");
console.log(rule.dependencies);
// [{ kind: "vm", id: "vm-123456" }]

The dependency runs one way. Deleting a rule never touches the VMs or networks it named.

What’s Not Built Yet

The model describes more than the edge serves today. These shapes validate in the type system but are refused (or unrouted) until the platform catches up:

  • Only exact names steer. Wildcard (*.acme.com) and catch-all (*) egress, and wildcard internal names, need the platform resolver — the guest’s /etc/hosts steering has no wildcards. An exact ingress *.acme.com does serve; the catch-all never does, since no certificate covers every name. socks5 egress is the exception: it is not steered by a name, so wildcard and catch-all destinations work there.
  • socks5 is egress only. { public } -> { vmId } is refused, and so is a destination of { public: true } — the rule has to name the proxy it leaves through.
  • tcp passthrough is public ingress only. Outbound ({ vmId } -> { public }) and VM-to-VM tcp rules are refused: both are brokered by the edge, which has to terminate the session to be the one answering it.
  • imap and imaps are public ingress only, for the same reason, and take no transform: the credential in an IMAP session is your user’s, not one the platform holds on your behalf.
  • There is no cleartext IMAP door. 993 is the only port the front listens on; 143 is where an imap rule lands, inside the VM.
  • Channel binding upstream is not available. A brokered Postgres session is two TLS sessions, so SCRAM-SHA-256-PLUS cannot be answered; an origin that offers no plain SCRAM-SHA-256 is refused. CancelRequest is not routed.
  • Transforms on internal services are not exposed. The edge terminates a VM-to-VM rule and could inject, but the API refuses a transform there for now.

Everything on this page outside this section is shipped and serving.

esc