Skip to content

Group-based vs full-isolation: firewall policies as data, not iptables muscle memory

9/5/2025Backend DevelopmentOpenVPN Control Plane11 min read

Group-based vs full-isolation: firewall policies as data, not iptables muscle memory

Two policy shapes — clients that can see their group, and clients that can see no one. Modelling the choice as a row instead of a hand-edited ruleset is what makes it auditable.

The decision this framework is for

You have a client-to-client VPN overlay. Some peers should reach each other; most should reach no one. The lazy answer is to SSH into the host and hand-edit the packet filter every time membership changes. That works until the third operator does it differently, the rules drift from whatever the support ticket said, and nobody can answer the only question that matters during an incident: who was allowed to talk to whom, and who decided that?

This framework is for the moment you stop treating the firewall as a config file someone edits and start treating it as a projection of stored intent. The control plane I built for a managed VPN supports two reachability shapes beyond the default — group-based (you can see members of your group, nobody else) and full-isolation (you can see no one). Both are declared as data — a groupId row in firewall_groups, a policy enum in config — and the kernel ruleset is compiled from that data. The commit that introduced them, feat(firewall): H1 — group-based + full-isolation politikaları ekle, added zero hand-written nft rules. It added a store and a compiler.

The framework

Call it intent as rows, rules as a projection. Four steps:

  1. Declare the policy shape as an enum, not a script. The deployment picks one of a closed set of reachability models. No free text.
  2. Store membership as data with one writer. Group assignment lives in a table behind a typed store, not in the ruleset.
  3. Compile intent into the kernel ruleset deterministically. A pure function turns the declared shape plus the current membership into the exact nft script — same input, same output, every time.
  4. Read the verdict back as counted facts. The same ruleset that enforces also counts, so the audit trail is a side effect of enforcement, not a separate logging bolt-on.

The payoff is that every reachability decision becomes reviewable the same way a code change is: a diff against stored state, not a recollection of what someone typed at 2am.

Each step with one paragraph of explanation

Declare the shape as an enum. In src/firewall/types.ts, the policy is a Zod enum with exactly three members. There is no policy: string anywhere downstream that could smuggle in a typo or an undocumented mode.

// src/firewall/types.ts
export const FirewallPolicySchema = z.enum(['same-subnet', 'group-based', 'full-isolation']);
export type FirewallPolicy = z.infer<typeof FirewallPolicySchema>;

export const NftablesConfigSchema = z.object({
  nftBin: z.string(),
  table: z.string(),
  tunIfaces: z.array(z.string()),
  policy: FirewallPolicySchema,
  /** Subnet prefixes whose intra-subnet traffic is allowed (one pair per entry). */
  allowedPairs: z.array(z.string()),
  // ...
});

The enum is the whole contract. A reviewer reading config knows the deployment is in one of three named states, and the type system refuses anything else at the boundary. full-isolation is not "we forgot to add allowed pairs" — it is a named, intentional choice that survives a restart and shows up in a code review.

Store membership as data with one writer. Group assignment lives in the firewall_groups SQLite table, fronted by GroupStore in src/firewall/GroupStore.ts. Nothing else writes group membership.

// src/firewall/GroupStore.ts
async setGroup(cn: string, groupId: string): Promise<void> {
  if (!cn) throw new Error('GroupStore.setGroup: cn is empty');
  if (!groupId) throw new Error('GroupStore.setGroup: groupId is empty');
  const db = await getDb();
  await db.$executeRaw`
    INSERT INTO firewall_groups (cn, group_id, updated_at)
    VALUES (${cn}, ${groupId}, ${Date.now()})
    ON CONFLICT (cn) DO UPDATE
      SET group_id   = excluded.group_id,
          updated_at = excluded.updated_at
  `;
  log.info({ cn, groupId }, 'firewall-group: set');
}

Every assignment carries an updated_at, the upsert is idempotent, and the store's own doc comment is blunt about the boundary: "Assignment is purely advisory — the enforcement happens in NftablesManager." The data store does not enforce; it records intent. That separation is the entire point — the row is the source of truth, and the kernel is downstream of it.

Compile intent into the ruleset deterministically. renderRuleset in src/firewall/policy.ts is a pure function: options in, an nft script out, no I/O. That property is what makes the system auditable — you can run the compiler in a test and assert on the exact bytes that will hit the kernel.

// src/firewall/policy.ts
export function renderRuleset(opts: RulesetOptions): string {
  // ...validation of table name, ifaces, CIDRs...
  return [
    `add table inet ${opts.table}`,
    `delete table inet ${opts.table}`,
    `table inet ${opts.table} {`,
    `  set allowed_pairs {`,
    `    type ipv4_addr . ipv4_addr`,
    `    flags interval${elements}`,
    `  }`,
    // counters...
    `  chain forward {`,
    `    type filter hook forward priority filter; policy accept;`,
    `    iifname ${ifaceSet} oifname != ${ifaceSet} counter name c_egress drop`,
    `    iifname != ${ifaceSet} oifname ${ifaceSet} counter name c_ingress drop`,
    `    iifname ${ifaceSet} oifname ${ifaceSet} ip saddr . ip daddr @allowed_pairs counter name c_same_subnet return`,
    `    iifname ${ifaceSet} oifname ${ifaceSet} counter name c_cross_subnet drop`,
    `  }`,
    `}`,
    '',
  ].join('\n');
}

The reachability decision collapses to a single set lookup: ip saddr . ip daddr @allowed_pairs. A packet whose source-destination pair is an element of allowed_pairs is counted and returned; everything else on the tunnel falls through to the drop. The script opens with add table ... ; delete table ... so applying it is atomic and idempotent — it rebuilds the table whether or not it already exists, in one transaction. No partial states, no "did the third rule apply" guessing.

Read the verdict back as counted facts. Every branch in that chain ends in a named counter — c_same_subnet, c_cross_subnet, c_egress, c_ingress. NftablesManager.readCounters() reads them straight back out of the kernel as JSON. Enforcement and observability are the same object, so "how much traffic did this policy drop" is a fact you query, not a log you hope was enabled.

Walk the framework through a real artifact in the target repo

The interesting part is how the shape changes what gets compiled. NftablesManager.ensure() does not bake membership into the boot ruleset for the two dynamic shapes:

// src/firewall/NftablesManager.ts
async ensure(): Promise<void> {
  // For `group-based` and `full-isolation`, the initial ruleset has no
  // allowed pairs. Group-based pairs are added dynamically via
  // `addGroupPair` as clients connect; full-isolation drops everything.
  const allowedPairs = this.cfg.policy === 'same-subnet' ? this.cfg.allowedPairs : [];
  const script = renderRuleset({
    table: this.cfg.table,
    tunIfaces: this.cfg.tunIfaces,
    allowedPairs,
    // ...
  });
  // nft 1.x rejects `-f -` when stdin is a pipe; write a 0600 temp file.
  const dir = mkdtempSync(join(tmpdir(), 'nft-'));
  const path = join(dir, 'ruleset.nft');
  try {
    writeFileSync(path, script, { mode: 0o600 });
    await this.runNft(['-f', path]);
  } finally {
    rmSync(dir, { recursive: true, force: true });
  }
}

For full-isolation, allowedPairs is [] and stays empty — the set has no elements, so the @allowed_pairs branch never matches and every tunnel-to-tunnel packet hits the c_cross_subnet drop. Isolation is not a special rule; it is the absence of any allowed pair. That is the cleanest possible expression of "see no one": the empty set.

For group-based, membership is injected at runtime as clients connect, not at boot. When two members of the same group are online, the connect handler calls addGroupPair:

// src/firewall/NftablesManager.ts
async addGroupPair(ip1: string, ip2: string): Promise<void> {
  if (this.cfg.policy !== 'group-based') return;
  if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(ip1)) throw new Error(`addGroupPair: invalid ip1 ${ip1}`);
  if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(ip2)) throw new Error(`addGroupPair: invalid ip2 ${ip2}`);
  const table = this.cfg.table;
  await this.runNft(['add', 'element', 'inet', table, 'allowed_pairs',
    `{ ${ip1}/32 . ${ip2}/32, ${ip2}/32 . ${ip1}/32 }`]);
  log.debug({ ip1, ip2 }, 'firewall: group pair added');
}

Two details earn their place here. First, the method is a no-op unless the policy is group-based — the shape enum gates the mutation, so you cannot accidentally punch a hole in a full-isolation deployment by calling the wrong method. Second, it inserts both directions — ip1/32 . ip2/32 and ip2/32 . ip1/32 — because the set is ordered pairs and reachability is symmetric. The /32 host masks come from GroupStore.getGroupMembers(groupId), which returns the CNs in a group; the connect handler resolves their current VPN IPs and pairs them. Membership is the data; the set element is its projection. When a client disconnects, removeGroupPair deletes both elements and tolerates them already being gone — a restart rebuilt the table from the empty set, so a stale delete is expected, not an error.

This is the framework in one trace: a groupId in a row becomes a pair of /32 elements in a kernel set, and the moment the row changes, the next connect compiles the new reality. Nobody edited a rule.

Where the framework fails

Be honest about the edges. The two dynamic shapes hold membership in the kernel's runtime set, not in the boot script — so a fresh ensure() rebuilds the table empty, and the allowed_pairs only repopulate as clients reconnect and the handlers re-issue addGroupPair. For a few seconds after a control-plane restart, a group-based deployment behaves like full-isolation until sessions re-announce. That is fail-closed, which is the right default for a security control, but it means "reachable" is eventually-consistent with the stored membership, not instantly so. If your SLA promises uninterrupted peer reachability across restarts, this shape will surprise you.

The second edge is the app-layer drops. AppFirewallController reacts to classified flows and inserts short-TTL block entries, and its own comment admits the limit: "The first one or two packets of the flow leak through — we accept that." The declarative model is exact for reachability but probabilistic for application protocol enforcement, because the latter depends on a classifier observing a flow before it can act. Don't sell the data-as-policy story as packet-perfect app filtering. It isn't, and the code says so.

Trade-off

The thing you give up is in-place expressiveness. A human at a nft prompt can express anything the kernel supports — odd port ranges, asymmetric rules, one-off exceptions for a single noisy peer. A closed enum of three shapes plus a symmetric pair set cannot. Every new reachability idea has to become a new modelled shape or a new kind of stored element before it can exist. That is slower than editing a rule by hand, and on day one it feels like bureaucracy. The bet is that a system you can diff, test, and replay beats a system you can improvise on — and that bet only pays off if you actually hold the line and refuse to add the one-off hand-edited rule "just this once." The first manual exception is the moment the audit trail starts lying.

Business impact

For the people who carry the pager and sign the security questionnaire, this changes one number: time-to-answer during an incident or an audit. When reachability is data, "prove client A could never reach client B last Tuesday" is a query against firewall_groups history and a counter readout, answerable in minutes, by anyone, without trusting a memory. When reachability is hand-edited iptables, that same question is an archaeology project across shell history and whatever got written down. For a managed-service operator, the difference is whether a compliance review is a meeting or a month — and whether a misconfiguration is a reviewable diff or a silent gap nobody finds until it's exploited.

What to do next

Pull up your own packet filter and ask one question: if I had to prove who could reach whom six months ago, what would I query? If the honest answer is "my shell history and my memory," you are one operator-turnover away from not being able to answer it at all. The cheapest first move is not a rewrite — it is making the policy shape an explicit, stored value (even a single enum column) and writing a pure function that renders your current rules from it. Once the rules are a projection of data, the test that asserts on the rendered ruleset writes itself, and the audit trail comes for free. Start with the enum. The set lookups can follow.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox